summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-11 15:24:37 -0800
committerskylar <me@casper>2026-11-11 15:24:37 -0800
commit9ccf951473e21a8b90c25161156fa09a9b227a7b (patch)
treee7bd9858a4cdbc5ae50069db8ca0cf868e436dec /src
parentd36c54d996f3c14ae811312dbbe621e12a39edde (diff)
Orrery 0.1.0: the galaxy, flight, cities, inventory and saves
Grows the derelict crawler into the whole game, and gets it ready to publish. - A galaxy of star systems joined by lanes, split between four peoples' territories, with a star map and a system map. Systems are modelled with Kepler orbits, moons inside their Hill spheres, belts past the frost line and stations at Lagrange points. - Flight around one place at a time: arcade flight with a throttle fallback where held keys can't be detected, mining rocks for ore, landing, docking at stations, and boarding wrecks that come and go by the calendar. - Your ship to walk about, and capital cities to land at: generated plans with walls, quarters and streets, building interiors, shops, taverns, scholars and armourers. - Fighting with a weapon and a gun ready, six damage types and how each monster takes them, and armour that guards, wears and can be mended. - Everything you own has a weight: a vim-keyed inventory, a pack and the ship's lockers, and trade in ore, salvage, arms and fuel. - Characters with a people and a ship of their own, saved as versioned JSON on every scene change and on quit, with a character sheet, a saves list and a pause menu wherever you are. - `orrery weapons`, `armour`, `galaxy`, `system` and `town` print the generators' output without the TUI. - Release: README install steps for macOS and Linux, a Makefile whose install also works with macOS's install, an MIT licence, and Cargo metadata with rust-version 1.88. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/app.rs511
-rw-r--r--src/cli.rs358
-rw-r--r--src/dungeon/armour.rs526
-rw-r--r--src/dungeon/arms.rs679
-rw-r--r--src/dungeon/crawl.rs1692
-rw-r--r--src/dungeon/mod.rs14
-rw-r--r--src/dungeon/ship.rs161
-rw-r--r--src/items.rs728
-rw-r--r--src/main.rs31
-rw-r--r--src/names/artifact.rs17
-rw-r--r--src/names/mod.rs13
-rw-r--r--src/save.rs790
-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
-rw-r--r--src/space/flight.rs434
-rw-r--r--src/space/galaxy.rs573
-rw-r--r--src/space/locale.rs406
-rw-r--r--src/space/mod.rs154
-rw-r--r--src/space/orbit.rs164
-rw-r--r--src/space/survey.rs365
-rw-r--r--src/space/system.rs1215
-rw-r--r--src/town/interior.rs1112
-rw-r--r--src/town/mod.rs850
-rw-r--r--src/town/plan.rs1230
-rw-r--r--src/town/words.rs633
-rw-r--r--src/trade.rs639
41 files changed, 25198 insertions, 322 deletions
diff --git a/src/app.rs b/src/app.rs
index e3fe72a..e09f4af 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,14 +1,25 @@
//! The game loop and the scene stack.
+use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::io;
+use std::path::PathBuf;
use std::time::{Duration, Instant};
use ratatui::DefaultTerminal;
-use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
+use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
-use crate::names::Artifact;
+use crate::dungeon::Pos;
+use crate::dungeon::arms::Dmg;
+use crate::dungeon::crawl::SavedCrawl;
+use crate::items::{self, Gear, Item, Stack};
+use crate::save::{self, Character, Record};
+use crate::scenes::inventory::Sort;
use crate::scenes::{Scene, Transition, title::Title};
+use crate::seed;
+use crate::space::galaxy::{self, Galaxy};
+use crate::space::system::{System, Wreck};
use crate::term::{self, KeyboardCaps};
+use crate::trade::Rack;
/// Simulation rate. Real-time scenes (flight) move by this many steps a
/// second; turn-based scenes (dungeons) can ignore ticks and react to keys.
@@ -17,11 +28,19 @@ const TICK: Duration = Duration::from_nanos(1_000_000_000 / TICKS_PER_SECOND as
/// After a stall (suspended with ^Z, a slow terminal), run at most this many
/// missed ticks, then carry on from now instead of fast-forwarding.
const MAX_CATCH_UP: u32 = 5;
+/// Without key releases, a held key's auto-repeats look like presses. After
+/// the scene changes, presses are dropped for as long as they keep coming
+/// closer together than this, as auto-repeats do.
+const REPEAT_GAP: Duration = Duration::from_millis(150);
/// State every scene can see.
pub struct Ctx {
/// The universe seed. Everything procedural derives from it (see `seed`).
pub seed: u64,
+ /// Who you're playing.
+ pub character: Character,
+ /// What they've done.
+ pub record: Record,
/// Ticks since the game started.
pub ticks: u64,
pub keyboard: KeyboardCaps,
@@ -29,36 +48,425 @@ pub struct Ctx {
/// `derive(seed, "derelict", n)`.
pub boarded: u64,
pub hold: Hold,
+ /// Wrecks you've boarded, by seed. There's nothing left aboard them.
+ pub stripped: HashSet<u64>,
+ /// Every star system, and the lanes between them.
+ pub galaxy: Galaxy,
+ /// Which of the galaxy's systems you're in: an index into `galaxy.sites`.
+ pub star: usize,
+ /// The systems you've been to. They, and the systems a lane joins to
+ /// them, are charted: you can plot a route through them.
+ pub visited: BTreeSet<usize>,
+ /// The star system you're in, generated whole.
+ pub system: System,
+ /// What to tell you when you next take the controls, after a journey
+ /// the map can describe better than the flight can.
+ pub arrival_note: Option<String>,
+ /// Days since the game began. It moves on only when you travel, and
+ /// everything in the system moves with it.
+ pub day: f64,
+ /// The day you set out for where you are now, while you're still
+ /// there. Wrecks that were calling then are still here, even if their
+ /// beacons have failed on the way: you came by their last fix. `None`
+ /// after a jump or a tow, which bring you somewhere you didn't choose.
+ pub departed: Option<f64>,
+ /// Where in the system you are: an index into `system.places`.
+ pub location: usize,
+ /// Whether your ship is set down at `location`, rather than flying.
+ pub landed: bool,
+ /// Better lenses fitted to the mining laser.
+ pub lenses: u32,
+ /// How each kind of monster takes each kind of damage, as far as you've
+ /// found out. Unlike what you carry, you keep this whatever happens.
+ pub bestiary: BTreeSet<(&'static str, Dmg)>,
+ /// The wreck you're aboard, as it stood after your last move, so a save
+ /// can bring you back to it. `None` when you're not in one.
+ pub crawl: Option<SavedCrawl>,
+ /// Armourers' racks you've traded at this fortnight, as you left them.
+ pub racks: Vec<Rack>,
+ /// How your inventory's lists are sorted.
+ pub inventory_sort: Sort,
+ /// The folder saves go in. Without one, nothing is saved.
+ pub saves: Option<PathBuf>,
+ /// This character's save file. There's none until you've made or
+ /// loaded a character, and nothing is saved until there is.
+ pub slot: Option<PathBuf>,
+ /// Why the last save failed, if it did.
+ pub save_error: Option<String>,
+ /// Keys held down right now. Only kept on terminals that report key
+ /// releases (`keyboard.key_release`); elsewhere there's no telling when
+ /// a key is let go, and this stays empty.
+ held: HashSet<KeyCode>,
+ /// Keys that were down when the scene last changed. They don't count as
+ /// held, and their auto-repeats are dropped, until they're let go: the
+ /// step that takes you out through an airlock shouldn't carry on
+ /// walking you about on the other side.
+ carried: HashSet<KeyCode>,
+ /// The same for terminals that don't report releases: while the scene
+ /// has only just changed, when the last key came.
+ settling: Option<Instant>,
}
impl Ctx {
pub fn new(seed: u64, keyboard: KeyboardCaps) -> Ctx {
- Ctx { seed, ticks: 0, keyboard, boarded: 0, hold: Hold::default() }
+ let galaxy = galaxy::generate(seed);
+ let star = galaxy.home;
+ let system = galaxy.system(star);
+ // Someone to be until you make a character of your own.
+ let culture = system.culture;
+ let character = Character {
+ name: culture.person(&mut seed::rng(seed::derive(seed, "you", 0))),
+ origin: culture,
+ ship: culture.word(&mut seed::rng(seed::derive(seed, "your ship", 0))),
+ };
+ Ctx {
+ character,
+ record: Record::default(),
+ landed: false,
+ crawl: None,
+ racks: Vec::new(),
+ inventory_sort: Sort::default(),
+ saves: None,
+ slot: None,
+ save_error: None,
+ location: system.home_place(),
+ system,
+ galaxy,
+ star,
+ visited: BTreeSet::from([star]),
+ arrival_note: None,
+ day: 0.0,
+ departed: None,
+ lenses: 0,
+ bestiary: BTreeSet::new(),
+ seed,
+ ticks: 0,
+ keyboard,
+ boarded: 0,
+ hold: Hold::default(),
+ stripped: HashSet::new(),
+ held: HashSet::new(),
+ carried: HashSet::new(),
+ settling: None,
+ }
+ }
+
+ /// Starts a new game for `character` in universe `seed`, keeping only
+ /// what belongs to the terminal: the clock, the keyboard, where saves go.
+ /// The slot is cleared; whoever calls this decides where the new game
+ /// saves.
+ /// You start at the seat of your own people, on their homeworld.
+ pub fn begin(&mut self, seed: u64, character: Character) {
+ let mut fresh = Ctx::new(seed, self.keyboard);
+ let seat = fresh.galaxy.seat(character.origin);
+ if seat != fresh.star {
+ fresh.visited.clear();
+ fresh.enter_system(seat);
+ fresh.location = fresh.system.home_place();
+ }
+ fresh.character = character;
+ fresh.ticks = self.ticks;
+ fresh.saves = self.saves.take();
+ fresh.held = std::mem::take(&mut self.held);
+ fresh.carried = std::mem::take(&mut self.carried);
+ fresh.settling = self.settling;
+ *self = fresh;
+ }
+
+ /// Arrives in another system, at its edge, in flight.
+ pub fn enter_system(&mut self, star: usize) {
+ self.star = star;
+ self.system = self.galaxy.system(star);
+ self.visited.insert(star);
+ self.location = self.system.edge_place();
+ self.landed = false;
+ self.departed = None;
+ }
+
+ /// Flies to `place` in this system, `days` away.
+ pub fn travel(&mut self, place: usize, days: f64) {
+ self.departed = Some(self.day);
+ self.day += days;
+ self.location = place;
+ }
+
+ /// The wrecks you know of in this system that you haven't stripped:
+ /// those whose beacons are calling, and those here you came by a
+ /// failed beacon's last fix. Oldest first.
+ pub fn wrecks(&self) -> Vec<Wreck> {
+ let s = &self.system;
+ let mut wrecks = s.beacons(self.day, &self.stripped);
+ wrecks.retain(|w| w.place != self.location);
+ wrecks.extend(s.wrecks_at(self.location, self.day, self.departed, &self.stripped));
+ wrecks.sort_by(|a, b| a.arrived.total_cmp(&b.arrived));
+ wrecks
+ }
+
+ /// Whether you're out of fuel somewhere no one sells any.
+ pub fn stranded(&self) -> bool {
+ self.hold.fuel == 0 && !self.system.sells_fuel()
+ }
+
+ /// Saves the game, if there's a character to save.
+ pub fn autosave(&mut self) {
+ self.save_error = save::write(self).err().map(|e| format!("couldn't save: {e}"));
+ }
+
+ pub fn is_held(&self, code: KeyCode) -> bool {
+ let code = normalise(code);
+ self.held.contains(&code) && !self.carried.contains(&code)
+ }
+
+ /// Whether the top scene should get this key event. Keeps track of
+ /// which keys are held, too.
+ fn admit(&mut self, key: &KeyEvent, now: Instant) -> bool {
+ let code = normalise(key.code);
+ if self.keyboard.key_release {
+ // Scenes get presses and auto-repeats; releases only update
+ // which keys are held.
+ return match key.kind {
+ KeyEventKind::Release => {
+ self.held.remove(&code);
+ self.carried.remove(&code);
+ false
+ }
+ KeyEventKind::Repeat if self.carried.contains(&code) => false,
+ _ => {
+ self.held.insert(code);
+ self.carried.remove(&code);
+ true
+ }
+ };
+ }
+ match self.settling {
+ _ if key.kind == KeyEventKind::Release => false,
+ Some(last) if now.saturating_duration_since(last) < REPEAT_GAP => {
+ self.settling = Some(now);
+ false
+ }
+ _ => {
+ self.settling = None;
+ true
+ }
+ }
+ }
+
+ /// What's on screen changed under keys that are still down, without
+ /// the scene changing (a summary popped up over it): treat them as the
+ /// scene changing would, so a held key doesn't dismiss it unread.
+ pub fn settle(&mut self) {
+ self.scene_changed(Instant::now());
+ }
+
+ /// The top scene changed: keys still down were for the old one.
+ fn scene_changed(&mut self, now: Instant) {
+ self.carried.clone_from(&self.held);
+ if !self.keyboard.key_release {
+ self.settling = Some(now);
+ }
}
}
-/// What you've brought back from expeditions.
-#[derive(Default)]
+/// Letters count as the same key whatever the shift state, so releasing
+/// shift mid-hold doesn't leave a key stuck down.
+fn normalise(code: KeyCode) -> KeyCode {
+ match code {
+ KeyCode::Char(c) => KeyCode::Char(c.to_ascii_lowercase()),
+ other => other,
+ }
+}
+
+/// Everything you own: money, ore and fuel, what you carry, and what's
+/// stowed aboard your ship.
pub struct Hold {
- pub artifacts: Vec<Artifact>,
- /// Salvage, counted by what it'll sell for.
+ /// Money, in ¤.
pub scrip: u32,
+ /// Tons of each kind of ore.
+ pub ore: BTreeMap<&'static str, u32>,
+ /// Tons of ore the hold can carry; cargo racks add more.
+ pub ore_capacity: u32,
+ /// What's on you: worn, ready and in your pack. It goes everywhere you
+ /// go, wrecks too, and if you don't get out of one, all but your own
+ /// torch and rivet gun stays there.
+ pub gear: Gear,
+ /// The ship's lockers, safe aboard whatever happens to you. Shops can
+ /// reach them, and send what you buy to them.
+ pub lockers: Vec<Stack>,
+ /// What you've put down on your ship's deck, and where.
+ pub deck: Vec<(Pos, Stack)>,
+ /// Jumps' worth of fuel in the tank. Each jump burns one, however long.
+ pub fuel: u32,
+ /// How many jumps' worth the tank holds; extra tanks add more.
+ pub fuel_capacity: u32,
+ /// What you owe, in ¤: a tow you couldn't pay for. What you earn pays it
+ /// off first.
+ pub debt: u32,
+}
+
+/// Where something you own is kept, for a shop.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Kept {
+ Pack(usize),
+ Lockers(usize),
+}
+
+impl Kept {
+ /// "", or " (in your ship)".
+ pub fn note(self) -> &'static str {
+ match self {
+ Kept::Pack(_) => "",
+ Kept::Lockers(_) => " (in your ship)",
+ }
+ }
+}
+
+impl Default for Hold {
+ fn default() -> Hold {
+ Hold {
+ scrip: 0,
+ ore: BTreeMap::new(),
+ ore_capacity: Hold::BASE_ORE_CAPACITY,
+ gear: Gear::default(),
+ lockers: Vec::new(),
+ deck: Vec::new(),
+ fuel: Hold::BASE_FUEL,
+ fuel_capacity: Hold::BASE_FUEL,
+ debt: 0,
+ }
+ }
+}
+
+impl Hold {
+ /// Tons of ore the hold carries before any racks are fitted.
+ pub const BASE_ORE_CAPACITY: u32 = 40;
+ /// Jumps the tank holds before any extra tanks are fitted.
+ pub const BASE_FUEL: u32 = 4;
+
+ /// Takes in money, paying off any debt first. Returns how much went to
+ /// the debt.
+ pub fn earn(&mut self, amount: u32) -> u32 {
+ let paid = amount.min(self.debt);
+ self.debt -= paid;
+ self.scrip += amount - paid;
+ paid
+ }
+
+ /// Pays a fee from what you have, and owes the rest. Returns how much
+ /// is owed.
+ pub fn charge(&mut self, fee: u32) -> u32 {
+ let paid = fee.min(self.scrip);
+ self.scrip -= paid;
+ self.debt += fee - paid;
+ fee - paid
+ }
+
+ pub fn ore_tons(&self) -> u32 {
+ self.ore.values().sum()
+ }
+
+ /// Everything a shop can reach: what's in your pack, then what's in
+ /// your lockers.
+ pub fn kept(&self) -> impl Iterator<Item = (Kept, &Stack)> {
+ let pack = self.gear.pack.iter().enumerate().map(|(i, s)| (Kept::Pack(i), s));
+ pack.chain(self.lockers.iter().enumerate().map(|(i, s)| (Kept::Lockers(i), s)))
+ }
+
+ pub fn kept_mut(&mut self, at: Kept) -> Option<&mut Stack> {
+ match at {
+ Kept::Pack(i) => self.gear.pack.get_mut(i),
+ Kept::Lockers(i) => self.lockers.get_mut(i),
+ }
+ }
+
+ /// Takes up to `count` of what's kept `at`.
+ pub fn take_kept(&mut self, at: Kept, count: u32) -> Stack {
+ match at {
+ Kept::Pack(i) => items::take(&mut self.gear.pack, i, count),
+ Kept::Lockers(i) => items::take(&mut self.lockers, i, count),
+ }
+ }
+
+ /// You lose everything you were `carrying` but your own torch and rivet
+ /// gun. If you'd left one in the lockers, you take it out again.
+ pub fn lose_carried(&mut self, carrying: &Gear) {
+ let lockers = &mut self.lockers;
+ self.gear = carrying.stripped(|gun| {
+ let own = |s: &Stack| matches!(&s.item, Item::Weapon(w) if w.kit && w.is_gun() == gun);
+ let i = lockers.iter().position(own)?;
+ match items::take(lockers, i, 1).item {
+ Item::Weapon(w) => Some(w),
+ _ => None,
+ }
+ });
+ }
+
+ /// How many artifacts you have, on you and aboard.
+ pub fn artifacts(&self) -> u32 {
+ self.kept().filter(|(_, s)| matches!(s.item, Item::Artifact(_))).map(|(_, s)| s.count).sum()
+ }
+
+ /// How many medpatches you have, on you and aboard.
+ pub fn medpatches(&self) -> u32 {
+ self.kept().filter(|(_, s)| s.item == Item::Medpatch).map(|(_, s)| s.count).sum()
+ }
+
+ /// Puts something you've bought in your pack if there's room, and in
+ /// your lockers if not. Says whether it's on you.
+ pub fn deliver(&mut self, stack: Stack) -> bool {
+ if self.gear.fits(&stack.item) >= stack.count {
+ self.gear.add(stack);
+ true
+ } else {
+ items::stow(&mut self.lockers, stack);
+ false
+ }
+ }
+
+ pub fn locker_mass(&self) -> u32 {
+ items::pile_mass(&self.lockers)
+ }
+
+ pub fn ore_room(&self) -> u32 {
+ self.ore_capacity.saturating_sub(self.ore_tons())
+ }
}
-pub fn run(seed: u64) -> io::Result<()> {
+/// Plays. Given a universe, starts a new character in it; otherwise picks up
+/// the character you last played, if there is one.
+pub fn run(universe: Option<u64>) -> io::Result<()> {
let (mut terminal, keyboard) = term::init()?;
- let ctx = Ctx::new(seed, keyboard);
- let result = run_loop(&mut terminal, ctx);
+ let mut ctx = Ctx::new(universe.unwrap_or_else(seed::from_clock), keyboard);
+ ctx.saves = save::default_dir();
+ if universe.is_none()
+ && let Some(dir) = &ctx.saves
+ {
+ let last = save::list(dir).into_iter().find(|s| s.summary.is_ok());
+ if let Some(slot) = last {
+ let _ = save::load(&slot.path, &mut ctx);
+ }
+ }
+ let result = run_loop(&mut terminal, &mut ctx);
+ ctx.autosave();
term::restore(keyboard);
result
}
-fn run_loop(terminal: &mut DefaultTerminal, mut ctx: Ctx) -> io::Result<()> {
- let mut stack: Vec<Box<dyn Scene>> = vec![Box::new(Title::new(&ctx))];
+/// After the stack changes: the new top scene catches up, and the game is
+/// saved.
+fn changed(stack: &mut [Box<dyn Scene>], ctx: &mut Ctx) {
+ ctx.scene_changed(Instant::now());
+ if let Some(top) = stack.last_mut() {
+ top.on_top(ctx);
+ }
+ ctx.autosave();
+}
+
+fn run_loop(terminal: &mut DefaultTerminal, ctx: &mut Ctx) -> io::Result<()> {
+ let mut stack: Vec<Box<dyn Scene>> = vec![Box::new(Title::new(ctx))];
let mut next_tick = Instant::now() + TICK;
while let Some(top) = stack.last() {
- terminal.draw(|frame| top.render(frame, &ctx))?;
+ terminal.draw(|frame| top.render(frame, ctx))?;
// Wait for input until the next tick is due, then take whatever else
// is already queued without waiting again.
@@ -71,23 +479,24 @@ fn run_loop(terminal: &mut DefaultTerminal, mut ctx: Ctx) -> io::Result<()> {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(());
}
- // Scenes get presses and auto-repeats. Releases only arrive on
- // terminals with `ctx.keyboard.key_release`; when flight needs
- // held keys, track them here before this filter.
- if key.kind == KeyEventKind::Release {
+ if !ctx.admit(&key, Instant::now()) {
continue;
}
let Some(top) = stack.last_mut() else { break };
- let transition = top.key(key, &mut ctx);
- apply(&mut stack, transition);
+ let transition = top.key(key, ctx);
+ if apply(&mut stack, transition) {
+ changed(&mut stack, ctx);
+ }
}
let mut caught_up = 0;
while Instant::now() >= next_tick {
let Some(top) = stack.last_mut() else { break };
ctx.ticks += 1;
- let transition = top.tick(&mut ctx);
- apply(&mut stack, transition);
+ let transition = top.tick(ctx);
+ if apply(&mut stack, transition) {
+ changed(&mut stack, ctx);
+ }
next_tick += TICK;
caught_up += 1;
if caught_up == MAX_CATCH_UP {
@@ -98,13 +507,69 @@ fn run_loop(terminal: &mut DefaultTerminal, mut ctx: Ctx) -> io::Result<()> {
Ok(())
}
-fn apply(stack: &mut Vec<Box<dyn Scene>>, transition: Transition) {
+/// Changes the scene stack, and says whether it did.
+fn apply(stack: &mut Vec<Box<dyn Scene>>, transition: Transition) -> bool {
match transition {
- Transition::None => {}
+ Transition::None => return false,
Transition::Push(scene) => stack.push(scene),
+ Transition::Open(scenes) => stack.extend(scenes),
Transition::Pop => {
stack.pop();
}
+ Transition::Replace(scenes) => {
+ stack.pop();
+ stack.extend(scenes);
+ }
+ Transition::Title => stack.truncate(1),
Transition::Quit => stack.clear(),
}
+ true
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn event(code: KeyCode, kind: KeyEventKind) -> KeyEvent {
+ KeyEvent::new_with_kind(code, KeyModifiers::NONE, kind)
+ }
+
+ #[test]
+ fn going_to_the_title_closes_everything_above_it() {
+ let ctx = Ctx::new(1, KeyboardCaps { key_release: false });
+ let mut stack: Vec<Box<dyn Scene>> = vec![Box::new(Title::new(&ctx))];
+ assert!(apply(&mut stack, Transition::Open(crate::scenes::flight::resume(&ctx))));
+ assert!(stack.len() > 1);
+ assert!(apply(&mut stack, Transition::Title));
+ assert_eq!(stack.len(), 1);
+ }
+
+ #[test]
+ fn keys_held_through_a_change_of_scene_stay_behind() {
+ let mut ctx = Ctx::new(1, KeyboardCaps { key_release: true });
+ let now = Instant::now();
+ assert!(ctx.admit(&event(KeyCode::Left, KeyEventKind::Press), now));
+ assert!(ctx.is_held(KeyCode::Left));
+ ctx.scene_changed(now);
+ assert!(!ctx.is_held(KeyCode::Left));
+ assert!(!ctx.admit(&event(KeyCode::Left, KeyEventKind::Repeat), now));
+ assert!(ctx.admit(&event(KeyCode::Right, KeyEventKind::Press), now), "others get through");
+ assert!(!ctx.admit(&event(KeyCode::Left, KeyEventKind::Release), now));
+ assert!(ctx.admit(&event(KeyCode::Left, KeyEventKind::Press), now));
+ assert!(ctx.is_held(KeyCode::Left));
+ }
+
+ #[test]
+ fn without_key_releases_auto_repeats_stay_behind() {
+ let mut ctx = Ctx::new(1, KeyboardCaps { key_release: false });
+ let start = Instant::now();
+ let at = |ms| start + Duration::from_millis(ms);
+ let left = event(KeyCode::Left, KeyEventKind::Press);
+ ctx.scene_changed(start);
+ assert!(!ctx.admit(&left, at(30)));
+ assert!(!ctx.admit(&left, at(60)));
+ assert!(!ctx.admit(&left, at(150)));
+ assert!(ctx.admit(&left, at(400)), "a press after a pause is a new one");
+ assert!(ctx.admit(&left, at(420)), "and from then on they all count");
+ }
}
diff --git a/src/cli.rs b/src/cli.rs
index 70e9156..88d775c 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,28 +1,62 @@
-//! Command-line arguments, `orrery names` and `orrery ship`.
+//! Command-line arguments, `orrery names`, `ship`, `weapons`, `armour`,
+//! `galaxy`, `system` and `town`.
+
+use std::collections::HashMap;
use crate::dungeon::Pos;
use crate::dungeon::crawl::Crawl;
+use crate::dungeon::{armour, arms};
+use crate::items::Item;
use crate::names::{self, Culture};
-use crate::scenes::derelict::{item_look, tile_look};
+use crate::scenes::derelict::tile_look;
+use crate::scenes::inventory::look;
use crate::seed;
+use crate::space::Vec2;
+use crate::space::galaxy::{self, HALF_HEIGHT, HALF_WIDTH};
+use crate::space::system::{Landing, PlaceKind, SystemKind};
+use crate::space::{orbit, survey};
+use crate::{scenes, town};
pub const USAGE: &str = "\
usage: orrery [--seed N]
orrery names [--culture NAME] [-n COUNT] [--seed N]
orrery ship [-n WHICH] [--seed N]
+ orrery weapons [-n COUNT] [--seed N]
+ orrery armour [-n COUNT] [--seed N]
+ orrery galaxy [--seed N]
+ orrery system [-n WHICH] [--seed N]
+ orrery town [-n WHICH] [--inside] [--seed N]
- --seed N universe seed; the same seed always makes the same universe
+ --seed N universe seed; the same seed always makes the same universe.
+ To play, without it you carry on as whoever you last played;
+ with it, you make a new character in that universe
names print generated names instead of playing
--culture NAME qaari, cant, houses or drift (default: all four)
-n COUNT how many of each kind to print (default: 8)
ship print a whole derelict, as you'd find it on boarding
-n WHICH which derelict: the first you'd board, the second... (default: 1)
+ weapons print weapons as you might find them aboard wrecks
+ -n COUNT how many (default: 12)
+ armour print armour as you might find it aboard wrecks
+ -n COUNT how many (default: 12)
+ galaxy print the star map: every system, its lanes and who holds it
+ system print a star system: orbits, moons, places
+ -n WHICH which system, numbered as `galaxy` lists them (default: the
+ one the universe started in)
+ town print a capital city in the system you start in
+ -n WHICH which capital: the homeworld's is 1, then outwards (default: 1)
+ --inside print the inside of one building of each kind, too
";
pub enum Command {
Play { seed: Option<u64> },
Names(NamesOpts),
Ship { seed: u64, which: u64 },
+ Weapons { seed: u64, count: usize },
+ Armour { seed: u64, count: usize },
+ Galaxy { seed: u64 },
+ System { seed: u64, which: Option<u64> },
+ Town { seed: u64, which: u64, inside: bool },
Help,
}
@@ -34,13 +68,22 @@ pub struct NamesOpts {
pub fn parse(args: &[String]) -> Result<Command, String> {
let mut args = args.iter().map(String::as_str).peekable();
- let sub = args.next_if(|a| matches!(*a, "names" | "ship"));
- let (mut seed, mut culture, mut count) = (None, None, None);
+ let sub = args.next_if(|a| {
+ matches!(*a, "names" | "ship" | "weapons" | "armour" | "galaxy" | "system" | "town")
+ });
+ let (mut seed, mut culture, mut count, mut inside) = (None, None, None, false);
while let Some(arg) = args.next() {
match arg {
"-h" | "--help" => return Ok(Command::Help),
"--seed" => seed = Some(number(args.next(), "--seed")?),
- "-n" if sub.is_some() => count = Some(number(args.next(), "-n")?),
+ "--inside" if sub == Some("town") => inside = true,
+ "-n" if matches!(
+ sub,
+ Some("names" | "ship" | "weapons" | "armour" | "system" | "town")
+ ) =>
+ {
+ count = Some(number(args.next(), "-n")?)
+ }
"--culture" if sub == Some("names") => {
let name = args.next().ok_or("--culture needs a value")?;
culture = Some(Culture::parse(name).ok_or(format!("no culture called {name:?}"))?);
@@ -54,6 +97,23 @@ pub fn parse(args: &[String]) -> Result<Command, String> {
seed: seed.unwrap_or_else(seed::from_clock),
which: count.unwrap_or(1),
}),
+ Some("weapons") => Ok(Command::Weapons {
+ seed: seed.unwrap_or_else(seed::from_clock),
+ count: count.unwrap_or(12) as usize,
+ }),
+ Some("armour") => Ok(Command::Armour {
+ seed: seed.unwrap_or_else(seed::from_clock),
+ count: count.unwrap_or(12) as usize,
+ }),
+ Some("galaxy") => Ok(Command::Galaxy { seed: seed.unwrap_or_else(seed::from_clock) }),
+ Some("system") => {
+ Ok(Command::System { seed: seed.unwrap_or_else(seed::from_clock), which: count })
+ }
+ Some("town") => Ok(Command::Town {
+ seed: seed.unwrap_or_else(seed::from_clock),
+ which: count.unwrap_or(1),
+ inside,
+ }),
_ => Ok(Command::Names(NamesOpts {
cultures: culture.map_or(Culture::ALL.to_vec(), |c| vec![c]),
count: count.unwrap_or(8) as usize,
@@ -80,7 +140,7 @@ pub fn print_ship(seed: u64, which: u64) {
} else if let Some(m) = crawl.monster_at(p) {
m.kind.glyph
} else if let Some(l) = crawl.loot_at(p) {
- item_look(&l.item).0
+ look(&l.stack.item).0
} else {
tile_look(map.tile(p)).0
}
@@ -91,14 +151,296 @@ pub fn print_ship(seed: u64, which: u64) {
let mut rooms: Vec<&str> = map.rooms.iter().map(|r| r.name).collect();
rooms.sort_unstable();
rooms.dedup();
- println!("\n@ you Θ airlock + door * artifact % salvage ! medpatch");
+ println!(
+ "\n@ you Θ airlock + door * artifact ( salvage ! medpatch ) weapon }} gun [ armor % food"
+ );
println!(
"monsters: {}",
crawl.monsters.iter().map(|m| m.kind.name).collect::<Vec<_>>().join(", ")
);
+ let weapons: Vec<String> = crawl
+ .loot
+ .iter()
+ .filter_map(|l| match &l.stack.item {
+ Item::Weapon(w) => Some(format!("{} ({})", w.name(), w.damage_text())),
+ _ => None,
+ })
+ .collect();
+ println!("weapons: {}", weapons.join(", "));
+ let armour: Vec<String> = crawl
+ .loot
+ .iter()
+ .filter_map(|l| match &l.stack.item {
+ Item::Armor(a) => Some(format!("{} ({})", a.name(), a.guards_text())),
+ _ => None,
+ })
+ .collect();
+ println!("armour: {}", armour.join(", "));
println!("rooms: {}", rooms.join(", "));
}
+/// Prints `count` weapons as you might find them aboard wrecks in universe
+/// `seed`, for tuning the tables in `dungeon::arms`.
+pub fn print_weapons(seed: u64, count: usize) {
+ print!("{}", arms::catalogue(&mut seed::rng(seed::derive(seed, "catalogue", 0)), count));
+}
+
+/// Prints `count` pieces of armour as you might find them aboard wrecks in
+/// universe `seed`, for tuning the tables in `dungeon::armour`.
+pub fn print_armour(seed: u64, count: usize) {
+ let mut rng = seed::rng(seed::derive(seed, "armour catalogue", 0));
+ print!("{}", armour::catalogue(&mut rng, count));
+}
+
+/// Prints the galaxy of universe `seed`: a map, then every system by who
+/// holds it, numbered for `orrery system -n`.
+pub fn print_galaxy(seed: u64) {
+ let g = galaxy::generate(seed);
+ // Two light-years to a column, four to a row, so it fits a terminal.
+ let (cols, rows) = ((HALF_WIDTH * 1.2) as usize, (HALF_HEIGHT * 0.6) as usize);
+ let cell = |p: Vec2| {
+ let col = ((p.x / HALF_WIDTH * 1.1 + 1.1) / 2.2 * cols as f64) as usize;
+ let row = ((1.1 - p.y / HALF_HEIGHT * 1.1) / 2.2 * rows as f64) as usize;
+ (col.min(cols - 1), row.min(rows - 1))
+ };
+ let mut grid = vec![vec![' '; cols]; rows];
+ for (i, s) in g.sites.iter().enumerate() {
+ for &j in s.lanes.iter().filter(|&&j| j > i) {
+ let to = g.sites[j].pos;
+ let steps = (s.pos.distance(to) * 2.0) as usize;
+ for k in 1..steps {
+ let (c, r) = cell(s.pos + (to - s.pos) * (k as f64 / steps as f64));
+ grid[r][c] = '·';
+ }
+ }
+ }
+ for (i, s) in g.sites.iter().enumerate() {
+ let (c, r) = cell(s.pos);
+ let letter = s.culture.adjective().chars().next().unwrap();
+ grid[r][c] = if g.is_seat(i) { letter } else { letter.to_ascii_lowercase() };
+ }
+ println!("A galaxy of {} systems (universe {seed})\n", g.sites.len());
+ for row in grid {
+ println!(" {}", row.into_iter().collect::<String>().trim_end());
+ }
+ println!("\n Q/q Qaari C/c Cant H/h High Houses D/d Drift (capitals: seats)");
+ for c in Culture::ALL {
+ let held: Vec<usize> = (0..g.sites.len()).filter(|&i| g.sites[i].culture == c).collect();
+ let seat = &g.sites[g.seat(c)].name;
+ println!("\n{}: {} systems, seat {seat}", c.name().to_uppercase(), held.len());
+ for i in held {
+ let s = &g.sites[i];
+ let start = if i == g.home { " (the universe began here)" } else { "" };
+ let border = s.neighbour.map_or(String::new(), |n| format!(", borders {}", n.name()));
+ let lanes = match s.lanes.len() {
+ 1 => "1 lane".to_string(),
+ n => format!("{n} lanes"),
+ };
+ println!(
+ " {:>3} {:<16}{:<22}{:<16}{lanes}{border}{start}",
+ i + 1,
+ s.name,
+ s.kind.name(),
+ s.star.name
+ );
+ }
+ }
+ let count = |k: SystemKind| g.sites.iter().filter(|s| s.kind == k).count();
+ let kinds: Vec<String> =
+ SystemKind::ALL.into_iter().map(|k| format!("{} {}", count(k), k.name())).collect();
+ println!("\n{}", kinds.join(", "));
+}
+
+/// Prints a star system in universe `seed`: the `which`th as `galaxy`
+/// numbers them, or the one the universe started in.
+pub fn print_system(seed: u64, which: Option<u64>) {
+ let g = galaxy::generate(seed);
+ let site = match which {
+ Some(n) if (1..=g.sites.len() as u64).contains(&n) => n as usize - 1,
+ Some(n) => {
+ println!("There's no system {n}: they're numbered 1 to {}.", g.sites.len());
+ return;
+ }
+ None => g.home,
+ };
+ let s = g.system(site);
+ let kind = match s.kind {
+ SystemKind::Seat => format!("the seat of {}", s.culture.name()),
+ k => format!("{} held by {}", k.name(), s.culture.name()),
+ };
+ println!("The {} system, a {}: {kind} (universe {seed})", s.name, s.star.name);
+ println!("\n {:<26}{:>7}{:>6}{:>9} what", "planet", "AU", "e", "year");
+ for (i, p) in s.planets.iter().enumerate() {
+ let people = p.people.map_or(String::new(), |c| format!(", {}", c.adjective()));
+ let home = if i == s.home { " (home)" } else { "" };
+ println!(
+ " {:<26}{:>7.2}{:>6.2}{:>8.0}d {}{people}{home}",
+ p.name, p.orbit.a, p.orbit.e, p.orbit.period, p.kind.name
+ );
+ for m in &p.moons {
+ let people = m.people.map_or(String::new(), |c| format!(", {}", c.adjective()));
+ let km = m.orbit.a * 149.6e6;
+ println!(
+ " moon {:<21}{:>6.0}k km{:>7.1}d {}{people}",
+ m.name,
+ km / 1000.0,
+ m.orbit.period,
+ m.kind.name
+ );
+ }
+ }
+ println!("\n {} lies from {:.2} to {:.2} AU", s.belt.name, s.belt.inner, s.belt.outer);
+ for st in &s.stations {
+ let point = if st.leading { "L4" } else { "L5" };
+ println!(" {} sits at {}'s {point} point", st.name, s.planets[st.planet].name);
+ }
+
+ let home = s.home_place();
+ let from = s.position(home, 0.0);
+ println!(
+ "\n places (⌂ capital, ¤ trade only, · neither), and the trip from {} on day 0:",
+ s.places[home].name
+ );
+ // The wrecks calling on day 0, when a new game starts.
+ let calling = s.wrecks_on(0.0);
+ for (i, place) in s.places.iter().enumerate() {
+ let indent = if matches!(place.kind, PlaceKind::Moon(..) | PlaceKind::Station(_)) {
+ " "
+ } else {
+ " "
+ };
+ let trip = if i == home {
+ "you are here".to_string()
+ } else {
+ format!("{:.1} days", orbit::plan_trip(from, 0.0, |d| s.position(i, d)))
+ };
+ let wrecks: Vec<String> = calling
+ .iter()
+ .filter(|w| w.place == i)
+ .map(|w| format!("wreck: {}, {} (until day {:.0})", w.name, w.description, w.leaves))
+ .collect();
+ let mark = match s.landing(i) {
+ Landing::Capital(_) => "⌂",
+ Landing::Market(_) => "¤",
+ _ => "·",
+ };
+ let name = format!("{indent}{}", place.name);
+ println!("{mark} {name:<30}{trip:<14}{}", wrecks.join("; "));
+ }
+}
+
+/// Prints the `which`th capital in the system you start in, in universe
+/// `seed`: the homeworld's first, then the others from the star outwards.
+/// With `inside`, prints the inside of one building of each kind too.
+pub fn print_town(seed: u64, which: u64, inside: bool) {
+ let s = crate::space::home_system(seed);
+ let home = s.home_place();
+ let mut capitals: Vec<(usize, Culture)> = (0..s.places.len())
+ .filter_map(|i| match s.landing(i) {
+ Landing::Capital(c) => Some((i, c)),
+ _ => None,
+ })
+ .collect();
+ capitals.sort_by_key(|&(i, _)| i != home);
+ let Some(&(place, culture)) = capitals.get(which.saturating_sub(1) as usize) else {
+ println!("There are only {} capitals in this system.", capitals.len());
+ return;
+ };
+ let people = survey::survey(&s, place, 0.0).population;
+ let land = town::Land::of(&s, place, 0.0).expect("a capital is on a planet or moon");
+ let t = town::generate(town::seed_for(s.seed, place), culture, town::size_for(people), land);
+ let theme = scenes::town::theme(&t);
+ println!(
+ "{}, the capital of {}: a city of {} on {:?} ground, {} by {} \
+ (universe {seed}, capital {which} of {})\n",
+ t.name,
+ s.places[place].name,
+ culture.name(),
+ t.land.terrain,
+ t.width(),
+ t.height(),
+ capitals.len()
+ );
+ let folk: HashMap<Pos, ()> = t.site.people.iter().map(|p| (p.pos, ())).collect();
+ for y in 0..t.height() {
+ let row: String = (0..t.width())
+ .map(|x| {
+ let p = Pos::new(x, y);
+ if folk.contains_key(&p) {
+ '&'
+ } else {
+ scenes::town::street_look(&t, p, &theme, 0).0
+ }
+ })
+ .collect();
+ println!("{}", row.trim_end());
+ }
+ println!("\nquarters:");
+ for q in &t.quarters {
+ println!(" {:<28}{:?}", q.name, q.kind);
+ }
+ println!("\nshops:");
+ let wide = t.shops.iter().map(|s| s.name.chars().count()).max().unwrap_or(0) + 2;
+ for shop in &t.shops {
+ let b = &t.buildings[shop.building];
+ let quarter = t.quarter_at(b.door).map_or("", |q| q.name.as_str());
+ println!(
+ " {:<wide$}{:<10}kept by {}, in {quarter}",
+ shop.name,
+ shop.kind.sign(),
+ shop.keeper
+ );
+ }
+ let mut kinds: Vec<(String, usize)> = Vec::new();
+ for b in &t.buildings {
+ let kind = format!("{:?}", b.kind);
+ match kinds.iter_mut().find(|(k, _)| *k == kind) {
+ Some((_, n)) => *n += 1,
+ None => kinds.push((kind, 1)),
+ }
+ }
+ let kinds: Vec<String> = kinds.iter().map(|(k, n)| format!("{n} {k}")).collect();
+ println!(
+ "\n{} buildings: {}\n{} people in the streets",
+ t.buildings.len(),
+ kinds.join(", "),
+ t.site.people.len()
+ );
+ if !inside {
+ return;
+ }
+ let mut shown: Vec<String> = Vec::new();
+ for (b, building) in t.buildings.iter().enumerate() {
+ let kind = format!("{:?}", building.kind);
+ if building.barred || shown.contains(&kind) {
+ continue;
+ }
+ shown.push(kind);
+ let inside = t.enter(b);
+ println!(
+ "\n{} ({} rooms, {} people)",
+ building.name,
+ inside.room_count(),
+ inside.site.people.len()
+ );
+ for y in 0..inside.site.height {
+ let row: String = (0..inside.site.width)
+ .map(|x| {
+ let p = Pos::new(x, y);
+ if p == inside.start {
+ '@'
+ } else if inside.site.person_at(p).is_some() {
+ '&'
+ } else {
+ scenes::town::inside_look(&t, &inside, p, &theme, 0).0
+ }
+ })
+ .collect();
+ println!(" {}", row.trim_end());
+ }
+ }
+}
+
fn number(value: Option<&str>, flag: &str) -> Result<u64, String> {
let value = value.ok_or(format!("{flag} needs a value"))?;
value.parse().map_err(|_| format!("{flag}: {value:?} isn't a whole number"))
diff --git a/src/dungeon/armour.rs b/src/dungeon/armour.rs
new file mode 100644
index 0000000..80357ea
--- /dev/null
+++ b/src/dungeon/armour.rs
@@ -0,0 +1,526 @@
+//! Armour: the kinds there are, the pieces you find aboard wrecks and buy
+//! at an armourer's, what they guard against, and what they're worth.
+//!
+//! Each piece guards against certain kinds of damage, taking that much off
+//! a blow of that kind (a blow always does at least 1). Every blow a piece
+//! softens wears it down a little: below half its durability it guards 1
+//! less against everything, and worn out it guards nothing at all, though
+//! it stays on you. An armourer mends it, for more the more worn it is and
+//! the finer.
+//!
+//! Like a weapon, a piece is a kind rolled up with a condition: worn ones
+//! don't last as long, fine ones carry an affix ("insulated",
+//! "reinforced"), and the rare relic carries two and a name of its own.
+
+use std::collections::BTreeMap;
+use std::fmt::Write as _;
+
+use rand::RngExt;
+use rand::seq::IndexedRandom;
+use serde::{Deserialize, Serialize};
+
+use super::arms::{self, Condition, Dmg, Source};
+use crate::items::Slot;
+use crate::names::{self, Culture, pick};
+use crate::seed::Rng;
+
+/// A piece of armour.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Armor {
+ /// What it's called, without an article: "insulated vac-helmet", or for
+ /// a relic its own name, "the Iron Vow".
+ pub title: String,
+ /// What it is: "a fine insulated vac-helmet". For a relic, the only
+ /// place that says what kind of thing it is.
+ pub description: String,
+ pub slot: Slot,
+ /// What it takes off a blow of each kind, while it's in good repair.
+ #[serde(default)]
+ pub guards: BTreeMap<Dmg, i32>,
+ /// Whether it keeps a wound closed: a cut that lands doesn't bleed.
+ #[serde(default)]
+ pub stanches: bool,
+ /// What's left of it: one less for every blow it softens.
+ #[serde(default)]
+ pub durability: u32,
+ /// What it has when it's whole.
+ #[serde(default)]
+ pub max_durability: u32,
+ #[serde(default = "sound")]
+ pub condition: Condition,
+ /// A relic with its own name.
+ #[serde(default)]
+ pub named: bool,
+ /// Gloves and boots: "a pair of mag-boots".
+ #[serde(default)]
+ pub pair: bool,
+ /// What it weighs, in grams.
+ pub mass: u32,
+}
+
+fn sound() -> Condition {
+ Condition::Sound
+}
+
+impl Armor {
+ /// "a vac-helmet", "a pair of mag-boots", "the Iron Vow".
+ pub fn name(&self) -> String {
+ if self.named {
+ self.title.clone()
+ } else if self.pair {
+ format!("a pair of {}", self.title)
+ } else {
+ names::a(&self.title)
+ }
+ }
+
+ /// "your vac-helmet", "the Iron Vow": the subject of a sentence.
+ pub fn yours(&self) -> String {
+ if self.named { self.title.clone() } else { format!("your {}", self.title) }
+ }
+
+ /// "the vac-helmet", "the Iron Vow".
+ pub fn the(&self) -> String {
+ if self.named { self.title.clone() } else { format!("the {}", self.title) }
+ }
+
+ /// "is", or "are" for a pair of something.
+ pub fn is(&self) -> &'static str {
+ if self.pair && !self.named { "are" } else { "is" }
+ }
+
+ /// Below half its durability: it guards 1 less against everything.
+ pub fn thin(&self) -> bool {
+ self.durability * 2 < self.max_durability
+ }
+
+ /// Worn out: it guards nothing until it's mended.
+ pub fn ruined(&self) -> bool {
+ self.durability == 0
+ }
+
+ /// What it takes off a blow of `dmg` as it is now.
+ pub fn guard(&self, dmg: Dmg) -> i32 {
+ let whole = self.guards.get(&dmg).copied().unwrap_or(0);
+ match () {
+ _ if self.ruined() => 0,
+ _ if self.thin() => (whole - 1).max(0),
+ _ => whole,
+ }
+ }
+
+ /// Whether it keeps a wound closed as it is now.
+ pub fn stanching(&self) -> bool {
+ self.stanches && !self.ruined()
+ }
+
+ /// What it guards against when it's whole: "pierce 1 · blunt 1 · stops
+ /// bleeding".
+ pub fn guards_text(&self) -> String {
+ let mut parts: Vec<String> = self
+ .guards
+ .iter()
+ .filter(|&(_, &g)| g > 0)
+ .map(|(d, g)| format!("{} {g}", d.name()))
+ .collect();
+ if self.stanches {
+ parts.push("stops bleeding".into());
+ }
+ if parts.is_empty() {
+ return "guards nothing".into();
+ }
+ parts.join(" · ")
+ }
+
+ /// "durability 12/30".
+ pub fn durability_text(&self) -> String {
+ format!("durability {}/{}", self.durability, self.max_durability)
+ }
+
+ /// One blow softened: it wears a little. Says so when that leaves it
+ /// thin or ruined.
+ pub fn wear_down(&mut self) -> Option<String> {
+ let (thin, ruined) = (self.thin(), self.ruined());
+ self.durability = self.durability.saturating_sub(1);
+ let who = capitalise(&self.yours());
+ if self.ruined() && !ruined {
+ Some(format!("{who} {} worn out: it guards nothing until it's mended.", self.is()))
+ } else if self.thin() && !thin {
+ Some(format!("{who} {} wearing thin: it guards 1 less.", self.is()))
+ } else {
+ None
+ }
+ }
+
+ /// Makes it whole again.
+ pub fn repair(&mut self) {
+ self.durability = self.max_durability;
+ }
+
+ /// Puts right whatever a save file got wrong: numbers that make sense,
+ /// and durability for a piece saved before it had any.
+ pub fn mend(&mut self) {
+ if self.max_durability == 0 {
+ self.max_durability = 20;
+ self.durability = 20;
+ }
+ self.durability = self.durability.min(self.max_durability);
+ self.guards.retain(|_, g| *g > 0);
+ for g in self.guards.values_mut() {
+ *g = (*g).min(9);
+ }
+ }
+}
+
+fn capitalise(s: &str) -> String {
+ let mut chars = s.chars();
+ chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect())
+}
+
+struct Kind {
+ /// What it might be called; one is picked for each piece found.
+ nouns: &'static [&'static str],
+ slot: Slot,
+ /// Gloves and boots.
+ pair: bool,
+ guards: &'static [(Dmg, i32)],
+ stanches: bool,
+ durability: u32,
+ /// What one weighs, in grams.
+ mass: u32,
+ /// How common it is, relative to the others.
+ weight: u32,
+ /// Where it's most likely to turn up.
+ found: &'static [Source],
+ /// Whose armourers stock it most.
+ people: Option<Culture>,
+}
+
+use Dmg::{Blunt, Burn, Cut, Hymn, Pierce, Shock};
+use Slot::{Body, Feet, Hands, Head};
+use Source::{Armoury, Crew, Rites, Tools};
+
+const QAARI: Option<Culture> = Some(Culture::Qaari);
+const CANT: Option<Culture> = Some(Culture::Cant);
+const HOUSES: Option<Culture> = Some(Culture::Houses);
+const DRIFT: Option<Culture> = Some(Culture::Drift);
+
+#[rustfmt::skip]
+const KINDS: &[Kind] = &[
+ // Heads.
+ Kind { nouns: &["vac-helmet", "pressure helm", "bubble-helm"], slot: Head, pair: false, guards: &[(Pierce, 1), (Blunt, 1)], stanches: false, durability: 24, mass: 1800, weight: 4, found: &[Tools, Armoury, Crew], people: DRIFT },
+ Kind { nouns: &["choir-hood", "cantor's cowl", "psalm-veil"], slot: Head, pair: false, guards: &[(Hymn, 2)], stanches: false, durability: 16, mass: 400, weight: 2, found: &[Rites], people: HOUSES },
+ Kind { nouns: &["dust-veil", "head-wrap", "sun-hood"], slot: Head, pair: false, guards: &[(Burn, 1), (Cut, 1)], stanches: false, durability: 16, mass: 300, weight: 2, found: &[Crew], people: QAARI },
+ Kind { nouns: &["lineman's hood", "rubber cowl", "dielectric cap"], slot: Head, pair: false, guards: &[(Shock, 2)], stanches: false, durability: 18, mass: 700, weight: 2, found: &[Tools], people: CANT },
+ Kind { nouns: &["crested helm", "sallet", "visored helm"], slot: Head, pair: false, guards: &[(Cut, 1), (Blunt, 1), (Hymn, 1)], stanches: false, durability: 30, mass: 2200, weight: 1, found: &[Armoury], people: HOUSES },
+ // Bodies.
+ Kind { nouns: &["flight-jacket", "deck coat", "quilted jerkin"], slot: Body, pair: false, guards: &[(Cut, 2), (Blunt, 1)], stanches: true, durability: 26, mass: 2500, weight: 4, found: &[Crew], people: None },
+ Kind { nouns: &["ceramic vest", "flak vest", "plate carrier"], slot: Body, pair: false, guards: &[(Pierce, 1), (Cut, 1), (Blunt, 1)], stanches: false, durability: 30, mass: 5500, weight: 3, found: &[Armoury], people: DRIFT },
+ Kind { nouns: &["hardsuit", "pressure suit", "vac-suit"], slot: Body, pair: false, guards: &[(Pierce, 2), (Blunt, 1)], stanches: false, durability: 34, mass: 7500, weight: 2, found: &[Tools, Armoury], people: DRIFT },
+ Kind { nouns: &["lineman's apron", "dielectric coat", "rubber smock"], slot: Body, pair: false, guards: &[(Shock, 2), (Burn, 1)], stanches: false, durability: 26, mass: 4000, weight: 2, found: &[Tools], people: CANT },
+ Kind { nouns: &["coil-plate", "grounded cuirass", "dielectric plate"], slot: Body, pair: false, guards: &[(Shock, 2), (Cut, 1), (Blunt, 1)], stanches: false, durability: 36, mass: 7000, weight: 1, found: &[Armoury, Tools], people: CANT },
+ Kind { nouns: &["cuirass", "breastplate", "half-plate"], slot: Body, pair: false, guards: &[(Cut, 2), (Blunt, 2)], stanches: false, durability: 40, mass: 8000, weight: 1, found: &[Armoury], people: HOUSES },
+ Kind { nouns: &["surplice", "chasuble", "vestment"], slot: Body, pair: false, guards: &[(Hymn, 2)], stanches: false, durability: 18, mass: 1200, weight: 2, found: &[Rites], people: HOUSES },
+ Kind { nouns: &["dust-cloak", "layered robe", "sun-wrap"], slot: Body, pair: false, guards: &[(Burn, 2), (Cut, 1)], stanches: false, durability: 22, mass: 1800, weight: 2, found: &[Crew], people: QAARI },
+ // Hands.
+ Kind { nouns: &["lineman's gauntlets", "rubber gauntlets", "dielectric gloves"], slot: Hands, pair: true, guards: &[(Shock, 2)], stanches: false, durability: 18, mass: 600, weight: 3, found: &[Tools], people: CANT },
+ Kind { nouns: &["work gloves", "rigger's gloves", "welding gloves"], slot: Hands, pair: true, guards: &[(Cut, 1), (Burn, 1)], stanches: false, durability: 14, mass: 400, weight: 4, found: &[Tools, Crew], people: None },
+ Kind { nouns: &["hand-wraps", "sand-gloves", "palm-wraps"], slot: Hands, pair: true, guards: &[(Burn, 1), (Cut, 1)], stanches: false, durability: 12, mass: 200, weight: 2, found: &[Crew], people: QAARI },
+ Kind { nouns: &["vac-gloves", "suit gloves", "pressure gloves"], slot: Hands, pair: true, guards: &[(Pierce, 1)], stanches: false, durability: 16, mass: 500, weight: 2, found: &[Tools, Armoury], people: DRIFT },
+ Kind { nouns: &["gauntlets", "mailed gloves", "vambraces"], slot: Hands, pair: true, guards: &[(Cut, 1), (Blunt, 1)], stanches: false, durability: 26, mass: 1200, weight: 2, found: &[Armoury], people: HOUSES },
+ Kind { nouns: &["psalm-cuffs", "chime-bracers", "prayer-cords"], slot: Hands, pair: true, guards: &[(Hymn, 1), (Cut, 1)], stanches: false, durability: 16, mass: 300, weight: 1, found: &[Rites], people: HOUSES },
+ // Feet.
+ Kind { nouns: &["mag-boots", "deck boots", "grav-boots"], slot: Feet, pair: true, guards: &[(Pierce, 1), (Blunt, 1)], stanches: false, durability: 30, mass: 2400, weight: 4, found: &[Tools, Crew], people: DRIFT },
+ Kind { nouns: &["lineman's boots", "rubber boots", "dielectric boots"], slot: Feet, pair: true, guards: &[(Shock, 1)], stanches: false, durability: 22, mass: 1600, weight: 2, found: &[Tools], people: CANT },
+ Kind { nouns: &["sand-boots", "dune-shoes", "wrapped sandals"], slot: Feet, pair: true, guards: &[(Burn, 1), (Cut, 1)], stanches: false, durability: 16, mass: 800, weight: 2, found: &[Crew], people: QAARI },
+ Kind { nouns: &["greaves", "sabatons", "plated boots"], slot: Feet, pair: true, guards: &[(Cut, 1), (Blunt, 1)], stanches: false, durability: 34, mass: 3000, weight: 1, found: &[Armoury], people: HOUSES },
+ Kind { nouns: &["pilgrim's sandals", "vigil slippers", "chapel boots"], slot: Feet, pair: true, guards: &[(Hymn, 1)], stanches: false, durability: 14, mass: 500, weight: 1, found: &[Rites], people: HOUSES },
+];
+
+/// A piece found where `source` things turn up, not in the best of repair.
+/// `better` shifts the odds toward finer ones: an armoury keeps its stock in
+/// better shape than a berth does.
+pub fn find(rng: &mut Rng, source: Source, better: bool) -> Armor {
+ let kind = KINDS
+ .choose_weighted(rng, |k| k.weight * if k.found.contains(&source) { 5 } else { 1 })
+ .expect("there are kinds of armour");
+ let roll = rng.random_range(0..100) + if better { 25 } else { 0 };
+ let condition = match roll {
+ ..25 => Condition::Worn,
+ 25..65 => Condition::Sound,
+ 65..94 => Condition::Fine,
+ _ => Condition::Relic,
+ };
+ let mut a = forge(rng, kind, condition);
+ let left = match condition {
+ Condition::Worn => rng.random_range(0.3..0.7),
+ Condition::Sound => rng.random_range(0.55..1.0),
+ Condition::Fine => rng.random_range(0.75..1.0),
+ Condition::Relic => 1.0,
+ };
+ a.durability = ((f64::from(a.max_durability) * left).round() as u32).clamp(1, a.max_durability);
+ a
+}
+
+/// A piece for an armourer's rack where `culture` lives, mended whole. Each
+/// people favours its own: Qaari wraps and veils against burns and cuts,
+/// Cant insulated plate against shocks, High House fine plate and warded
+/// pieces against hymns, Drift patched vac-gear against piercing. A `relic`
+/// is rare and dear.
+pub fn stocked(rng: &mut Rng, culture: Culture, relic: bool) -> Armor {
+ let kind = KINDS
+ .choose_weighted(rng, |k| k.weight * if k.people == Some(culture) { 5 } else { 1 })
+ .expect("there are kinds of armour");
+ let fine = if culture == Culture::Houses { 20 } else { 0 };
+ let condition = match rng.random_range(0..100) + fine {
+ _ if relic => Condition::Relic,
+ ..15 => Condition::Worn,
+ 15..70 => Condition::Sound,
+ _ => Condition::Fine,
+ };
+ forge(rng, kind, condition)
+}
+
+/// What a piece is worth to an armourer whole, in scrip: more for guarding
+/// more, keeping wounds closed and lasting longer, and a good deal more for
+/// a fine one or a relic.
+pub fn value(a: &Armor) -> u32 {
+ let guards: i32 = a.guards.values().map(|&g| g.max(0)).sum();
+ let guards = f64::from(guards) + if a.stanches { 1.5 } else { 0.0 };
+ let lasting = 0.6 + f64::from(a.max_durability.min(60)) / 75.0;
+ let condition = match a.condition {
+ Condition::Worn => 0.8,
+ Condition::Sound => 1.0,
+ Condition::Fine => 1.25,
+ Condition::Relic => 3.0,
+ };
+ (12.0 * (guards * lasting).powf(1.25) * condition).round().max(1.0) as u32
+}
+
+/// What it's worth as it is: half its whole value when it's worn out, the
+/// rest coming back as it's mended.
+pub fn worth(a: &Armor) -> f64 {
+ f64::from(value(a)) - mending(a)
+}
+
+/// What mending it whole is worth: half its value, for one worn out.
+pub fn mending(a: &Armor) -> f64 {
+ if a.max_durability == 0 {
+ return 0.0;
+ }
+ let wear = f64::from(a.max_durability.saturating_sub(a.durability));
+ f64::from(value(a)) * 0.5 * wear / f64::from(a.max_durability)
+}
+
+fn forge(rng: &mut Rng, kind: &Kind, condition: Condition) -> Armor {
+ let noun = pick(kind.nouns, rng);
+ let mut a = Armor {
+ title: String::new(),
+ description: String::new(),
+ slot: kind.slot,
+ guards: kind.guards.iter().copied().collect(),
+ stanches: kind.stanches,
+ durability: 0,
+ max_durability: kind.durability,
+ condition,
+ named: false,
+ pair: kind.pair,
+ mass: kind.mass,
+ };
+ // No two quite alike.
+ a.max_durability = (a.max_durability as i32 + rng.random_range(-2..=2)).max(6) as u32;
+ a.mass = (f64::from(a.mass) * rng.random_range(0.9..1.1)).round() as u32;
+
+ let mut words = Vec::new();
+ match condition {
+ Condition::Worn => {
+ a.max_durability = (f64::from(a.max_durability) * 0.7).round() as u32;
+ words.push(pick(WORN, rng));
+ }
+ Condition::Sound => {}
+ Condition::Fine => words.extend(affixes(rng, &mut a, noun, 1)),
+ Condition::Relic => {
+ a.max_durability = (f64::from(a.max_durability) * 1.25).round() as u32;
+ words.extend(affixes(rng, &mut a, noun, 2));
+ }
+ }
+ a.durability = a.max_durability;
+
+ let mut what = words.join(" ");
+ if !what.is_empty() {
+ what.push(' ');
+ }
+ what.push_str(noun);
+ a.description = if a.pair { format!("a pair of {what}") } else { names::a(&what) };
+ if condition == Condition::Relic {
+ a.title = arms::relic_name(rng);
+ a.named = true;
+ } else {
+ a.title = what;
+ }
+ a
+}
+
+/// Up to `n` different affixes that suit the piece, applied; returns their
+/// words. None that the noun already says.
+fn affixes(rng: &mut Rng, a: &mut Armor, noun: &str, n: usize) -> Vec<&'static str> {
+ let mut words = Vec::new();
+ for _ in 0..n {
+ let fits: Vec<&Affix> = AFFIXES
+ .iter()
+ .filter(|f| (f.fits)(a) && !words.contains(&f.word) && !noun.contains(f.word))
+ .collect();
+ let Some(affix) = fits.choose(rng) else { break };
+ (affix.apply)(a);
+ words.push(affix.word);
+ }
+ words
+}
+
+struct Affix {
+ word: &'static str,
+ fits: fn(&Armor) -> bool,
+ apply: fn(&mut Armor),
+}
+
+fn more(a: &mut Armor, dmg: Dmg) {
+ *a.guards.entry(dmg).or_insert(0) += 1;
+}
+
+fn scaled(x: u32, k: f64) -> u32 {
+ (f64::from(x) * k).round() as u32
+}
+
+#[rustfmt::skip]
+const AFFIXES: &[Affix] = &[
+ Affix { word: "padded", fits: |_| true, apply: |a| more(a, Blunt) },
+ Affix { word: "mail-lined", fits: |a| a.slot != Feet, apply: |a| more(a, Cut) },
+ Affix { word: "ceramic-plated", fits: |a| matches!(a.slot, Head | Body), apply: |a| { more(a, Pierce); a.mass = scaled(a.mass, 1.2) } },
+ Affix { word: "insulated", fits: |_| true, apply: |a| more(a, Shock) },
+ Affix { word: "fireproofed", fits: |_| true, apply: |a| more(a, Burn) },
+ Affix { word: "warded", fits: |_| true, apply: |a| more(a, Hymn) },
+ Affix { word: "reinforced", fits: |_| true, apply: |a| { a.max_durability = scaled(a.max_durability, 1.5); a.mass = scaled(a.mass, 1.15) } },
+ Affix { word: "styptic-lined", fits: |a| a.slot == Body && !a.stanches, apply: |a| a.stanches = true },
+ Affix { word: "featherweight", fits: |a| a.mass >= 1000, apply: |a| a.mass = scaled(a.mass, 0.65) },
+];
+
+/// How a worn piece looks.
+const WORN: &[&str] =
+ &["scuffed", "patched", "battered", "taped-up", "grimy", "scorched", "stained", "threadbare"];
+
+/// A few lines describing `n` pieces found in a run of wrecks, for
+/// `orrery armour`.
+pub fn catalogue(rng: &mut Rng, n: usize) -> String {
+ let mut out = String::new();
+ let sources = [Armoury, Crew, Tools, Rites];
+ for i in 0..n {
+ let a = find(rng, sources[i % sources.len()], false);
+ let _ = match a.named {
+ true => writeln!(out, "{} ({}, {})", a.name(), a.description, a.slot.name()),
+ false => writeln!(out, "{} ({})", a.name(), a.slot.name()),
+ };
+ let _ = writeln!(out, " {}", a.guards_text());
+ let _ = writeln!(
+ out,
+ " {} · {:.1} kg · worth {} ¤ whole",
+ a.durability_text(),
+ f64::from(a.mass) / 1000.0,
+ value(&a)
+ );
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::seed;
+
+ #[test]
+ fn found_armour_is_sound() {
+ let mut rng = seed::rng(7);
+ let mut titles = std::collections::HashSet::new();
+ for i in 0..2000 {
+ let source = [Tools, Armoury, Crew, Rites][i % 4];
+ let a = find(&mut rng, source, i % 3 == 0);
+ assert!(a.max_durability >= 4, "{a:?}");
+ assert!((1..=a.max_durability).contains(&a.durability), "{a:?}");
+ assert!(a.guards.values().all(|&g| (1..=5).contains(&g)), "{a:?}");
+ assert!(!a.guards.is_empty() && a.mass > 0);
+ assert!(!a.title.is_empty() && !a.description.is_empty());
+ assert!(!a.description.contains(" "), "{}", a.description);
+ let mut saved = a.clone();
+ saved.mend();
+ assert_eq!(saved, a, "nothing to put right");
+ titles.insert(a.title.clone());
+ }
+ assert!(titles.len() > 150, "only {} different pieces", titles.len());
+ }
+
+ #[test]
+ fn worn_thin_it_guards_less_and_worn_out_nothing() {
+ let mut a = stocked(&mut seed::rng(1), Culture::Drift, false);
+ a.guards = [(Pierce, 2), (Blunt, 1)].into_iter().collect();
+ a.max_durability = 10;
+ a.durability = 10;
+ assert_eq!((a.guard(Pierce), a.guard(Blunt), a.guard(Cut)), (2, 1, 0));
+ let mut news = Vec::new();
+ for _ in 0..10 {
+ news.extend(a.wear_down());
+ }
+ assert_eq!(news.len(), 2, "once when it's thin, once when it's gone: {news:?}");
+ assert!(news[0].contains("wearing thin") && news[1].contains("worn out"));
+ assert_eq!(a.guard(Pierce), 0);
+ a.durability = 4;
+ assert_eq!((a.guard(Pierce), a.guard(Blunt)), (1, 0), "1 less below half");
+ a.repair();
+ assert_eq!(a.guard(Pierce), 2);
+ }
+
+ #[test]
+ fn mending_costs_more_the_more_worn_and_the_finer() {
+ let mut rng = seed::rng(2);
+ let mut a = stocked(&mut rng, Culture::Houses, false);
+ let whole = f64::from(value(&a));
+ assert_eq!(mending(&a), 0.0);
+ a.durability = 0;
+ assert!((mending(&a) - whole / 2.0).abs() < 1e-9, "half its price from ruined");
+ assert!((worth(&a) - whole / 2.0).abs() < 1e-9);
+ a.durability = a.max_durability / 2;
+ let half_worn = mending(&a);
+ assert!(half_worn > 0.0 && half_worn < whole / 2.0);
+ let mut relic = a.clone();
+ relic.condition = Condition::Relic;
+ assert!(mending(&relic) > half_worn, "the finer, the dearer");
+ }
+
+ #[test]
+ fn racks_lean_to_their_peoples_armour() {
+ let mut rng = seed::rng(3);
+ for culture in Culture::ALL {
+ let ours = (0..400)
+ .filter(|_| {
+ let a = stocked(&mut rng, culture, false);
+ KINDS.iter().any(|k| {
+ k.people == Some(culture) && k.nouns.iter().any(|n| a.title.ends_with(n))
+ })
+ })
+ .count();
+ assert!(ours > 160, "{} stock only {ours} of their own in 400", culture.name());
+ let a = stocked(&mut rng, culture, false);
+ assert_eq!(a.durability, a.max_durability, "a rack's pieces are whole");
+ }
+ let relic = stocked(&mut rng, Culture::Cant, true);
+ assert!(relic.named && relic.condition == Condition::Relic);
+ }
+
+ #[test]
+ fn pairs_read_as_pairs() {
+ let mut rng = seed::rng(4);
+ let boots =
+ (0..).map(|_| find(&mut rng, Tools, false)).find(|a| a.pair && !a.named).unwrap();
+ assert!(boots.name().starts_with("a pair of "));
+ assert_eq!(boots.is(), "are");
+ assert!(boots.yours().starts_with("your "));
+ }
+}
diff --git a/src/dungeon/arms.rs b/src/dungeon/arms.rs
new file mode 100644
index 0000000..d37e1c8
--- /dev/null
+++ b/src/dungeon/arms.rs
@@ -0,0 +1,679 @@
+//! Weapons: the kinds of damage, the kinds of weapon, the ones you find
+//! aboard wrecks (each with its own name and stats), and the geometry of a
+//! shot. The rules for using them live in `crawl`.
+//!
+//! A found weapon is a kind (a cleaver, a scattergun...) rolled up with a
+//! condition: worn things hit softer, fine ones carry an affix ("serrated",
+//! "long-barrelled"), and the rare relic carries two and a name of its own.
+
+use std::fmt::Write as _;
+
+use rand::RngExt;
+use rand::seq::IndexedRandom;
+use serde::{Deserialize, Serialize};
+
+use super::Pos;
+use crate::names::{self, Culture, pick};
+use crate::seed::Rng;
+
+type Rgb = (u8, u8, u8);
+
+/// The kinds of damage. Each does something besides hurting (see `about`),
+/// and every monster takes each kind differently.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Dmg {
+ Cut,
+ Blunt,
+ Pierce,
+ Burn,
+ Shock,
+ Hymn,
+}
+
+impl Dmg {
+ pub const ALL: [Dmg; 6] = [Dmg::Cut, Dmg::Blunt, Dmg::Pierce, Dmg::Burn, Dmg::Shock, Dmg::Hymn];
+
+ pub fn name(self) -> &'static str {
+ match self {
+ Dmg::Cut => "cut",
+ Dmg::Blunt => "blunt",
+ Dmg::Pierce => "pierce",
+ Dmg::Burn => "burn",
+ Dmg::Shock => "shock",
+ Dmg::Hymn => "hymn",
+ }
+ }
+
+ pub fn rgb(self) -> Rgb {
+ match self {
+ Dmg::Cut => (230, 90, 90),
+ Dmg::Blunt => (200, 170, 130),
+ Dmg::Pierce => (200, 205, 215),
+ Dmg::Burn => (255, 150, 60),
+ Dmg::Shock => (110, 200, 255),
+ Dmg::Hymn => (200, 150, 255),
+ }
+ }
+
+ /// What it does besides the damage.
+ pub fn about(self) -> &'static str {
+ match self {
+ Dmg::Cut => "Cuts make the living bleed, a little less each turn.",
+ Dmg::Blunt => "Blows knock things back; into a bulkhead, they stagger.",
+ Dmg::Pierce => "Goes through armour; fast rounds go on into what's behind.",
+ Dmg::Burn => "Sets things alight for a few turns.",
+ Dmg::Shock => "Can stun: the target loses its next turn.",
+ Dmg::Hymn => "Old songs against what shouldn't be. Can steady you.",
+ }
+ }
+
+ /// "slashes", as in "your cleaver slashes the rust-mite".
+ pub fn verb(self) -> &'static str {
+ match self {
+ Dmg::Cut => "slashes",
+ Dmg::Blunt => "smashes",
+ Dmg::Pierce => "stabs",
+ Dmg::Burn => "burns",
+ Dmg::Shock => "jolts",
+ Dmg::Hymn => "rings against",
+ }
+ }
+}
+
+/// What makes a weapon a gun.
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Gun {
+ /// How far a shot flies, in cells.
+ pub range: i32,
+ /// How far a shot can stray either side of where you aim, in degrees.
+ /// Most shots stray less than this; up close it barely matters.
+ pub spread: f64,
+ /// Projectiles per shot. Each strays on its own.
+ pub pellets: u32,
+ /// Shots before you have to reload.
+ pub magazine: u32,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Weapon {
+ /// What it's called, without an article: "serrated cleaver", or for a
+ /// relic its own name, "Khaseem's Mercy".
+ pub title: String,
+ /// What it is: "a fine serrated cleaver". For a relic, the only place
+ /// that says what kind of thing it is.
+ pub description: String,
+ pub dmg: Dmg,
+ pub damage: (i32, i32),
+ /// For blows, the chance one lands. Guns ignore it: where a shot lands
+ /// is down to spread.
+ pub accuracy: f64,
+ /// The chance a hit lands twice as hard.
+ pub crit: f64,
+ /// How strongly its damage's extra takes hold: bleeding added, turns
+ /// alight, % chance to knock back, % to stun, things a round passes
+ /// through, % chance to steady you. See `effect_text`.
+ pub effect: u32,
+ pub gun: Option<Gun>,
+ /// Rounds left in the magazine.
+ pub loaded: u32,
+ /// Part of your own kit, not something you found: never dropped, and
+ /// never lost.
+ pub kit: bool,
+ /// A relic with its own name.
+ pub named: bool,
+ pub condition: Condition,
+ /// What it weighs, in grams. Saves from before weight have none, and
+ /// `mend` estimates it.
+ #[serde(default)]
+ pub mass: u32,
+}
+
+impl Weapon {
+ /// "a serrated cleaver", "Khaseem's Mercy".
+ pub fn name(&self) -> String {
+ if self.named {
+ self.title.clone()
+ } else if self.kit {
+ format!("your {}", self.title)
+ } else {
+ names::a(&self.title)
+ }
+ }
+
+ /// "your serrated cleaver", "Khaseem's Mercy": the subject of a sentence.
+ pub fn yours(&self) -> String {
+ if self.named { self.title.clone() } else { format!("your {}", self.title) }
+ }
+
+ pub fn is_gun(&self) -> bool {
+ self.gun.is_some()
+ }
+
+ /// "bleeds 2", "knockback 35%"; nothing if it does nothing extra.
+ pub fn effect_text(&self) -> Option<String> {
+ let n = self.effect;
+ if n == 0 {
+ return None;
+ }
+ Some(match self.dmg {
+ Dmg::Cut => format!("bleeds {n}"),
+ Dmg::Blunt => format!("knockback {n}%"),
+ Dmg::Pierce => format!("passes through {n}"),
+ Dmg::Burn => format!("ignites {n}"),
+ Dmg::Shock => format!("stuns {n}%"),
+ Dmg::Hymn => format!("steadies {n}%"),
+ })
+ }
+
+ /// "cut 3–6", the damage.
+ pub fn damage_text(&self) -> String {
+ let (lo, hi) = self.damage;
+ let pellets = self.gun.map_or(1, |g| g.pellets);
+ let times = if pellets > 1 { format!("×{pellets}") } else { String::new() };
+ let damage = if lo == hi { lo.to_string() } else { format!("{lo}–{hi}") };
+ format!("{} {damage}{times}", self.dmg.name())
+ }
+
+ /// Every stat, for the pack screen: "cut 3–6 · hits 80% · crit 5%".
+ pub fn stats(&self) -> Vec<String> {
+ let mut first = vec![self.damage_text()];
+ match self.gun {
+ Some(g) => {
+ first.push(format!("range {}", g.range));
+ first.push(format!("spread ±{}°", trim(g.spread)));
+ }
+ None => first.push(format!("hits {}%", percent(self.accuracy))),
+ }
+ let mut second = Vec::new();
+ if let Some(g) = self.gun {
+ second.push(format!("{}/{} loaded", self.loaded, g.magazine));
+ }
+ second.push(format!("crit {}%", percent(self.crit)));
+ second.extend(self.effect_text());
+ vec![first.join(" · "), second.join(" · ")]
+ }
+
+ pub fn reload(&mut self) {
+ if let Some(g) = self.gun {
+ self.loaded = g.magazine;
+ }
+ }
+
+ /// Puts right whatever a save file got wrong: numbers that make sense,
+ /// and a weight for a weapon saved before things had one.
+ pub fn mend(&mut self) {
+ self.damage.0 = self.damage.0.max(1);
+ self.damage.1 = self.damage.1.max(self.damage.0);
+ self.accuracy = self.accuracy.clamp(0.0, 1.0);
+ self.crit = self.crit.clamp(0.0, 1.0);
+ if let Some(g) = &mut self.gun {
+ g.range = g.range.max(1);
+ g.pellets = g.pellets.clamp(1, 12);
+ g.magazine = g.magazine.max(1);
+ g.spread = if g.spread.is_finite() { g.spread.clamp(0.0, 90.0) } else { 0.0 };
+ self.loaded = self.loaded.min(g.magazine);
+ }
+ if self.mass == 0 {
+ self.mass = self.estimated_mass();
+ }
+ }
+
+ /// What a weapon saved without a weight probably weighs: its kind's,
+ /// going by what it's called.
+ fn estimated_mass(&self) -> u32 {
+ let own = [cutting_torch(), rivet_gun()];
+ if self.kit {
+ return own.iter().find(|w| w.title == self.title).map_or(2000, |w| w.mass);
+ }
+ KINDS
+ .iter()
+ .find(|k| {
+ k.nouns.iter().any(|n| self.description.contains(n) || self.title.contains(n))
+ })
+ .map_or(if self.is_gun() { 3000 } else { 1500 }, |k| k.mass)
+ }
+}
+
+fn percent(p: f64) -> i32 {
+ (p * 100.0).round() as i32
+}
+
+/// 1.5 as "1.5", 6.0 as "6".
+fn trim(x: f64) -> String {
+ let s = format!("{x:.1}");
+ s.strip_suffix(".0").map_or(s.clone(), str::to_string)
+}
+
+/// Where a kind of weapon turns up most.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Source {
+ /// Engine rooms, holds, machine shops.
+ Tools,
+ /// Armouries and bridges.
+ Armoury,
+ /// Berths and galleys: what the crew kept to hand.
+ Crew,
+ /// Chapels and reliquaries.
+ Rites,
+}
+
+struct Kind {
+ /// What it might be called; one is picked for each weapon found.
+ nouns: &'static [&'static str],
+ dmg: Dmg,
+ damage: (i32, i32),
+ accuracy: f64,
+ effect: u32,
+ gun: Option<Gun>,
+ /// How common it is, relative to the others.
+ weight: u32,
+ /// Where it's most likely to turn up.
+ found: &'static [Source],
+ /// What one weighs, in grams.
+ mass: u32,
+}
+
+const fn gun(range: i32, spread: f64, pellets: u32, magazine: u32) -> Option<Gun> {
+ Some(Gun { range, spread, pellets, magazine })
+}
+
+use Source::{Armoury, Crew, Rites, Tools};
+
+#[rustfmt::skip]
+const KINDS: &[Kind] = &[
+ // Blows: walk into something to use them.
+ Kind { nouns: &["cleaver", "hacking-blade", "machete"], dmg: Dmg::Cut, damage: (3, 6), accuracy: 0.80, effect: 1, gun: None, weight: 4, found: &[Crew, Tools], mass: 1200 },
+ Kind { nouns: &["shiv", "boarding knife", "galley knife"], dmg: Dmg::Cut, damage: (1, 4), accuracy: 0.95, effect: 2, gun: None, weight: 4, found: &[Crew], mass: 300 },
+ Kind { nouns: &["bone-saw", "chain-blade", "rip-saw"], dmg: Dmg::Cut, damage: (2, 7), accuracy: 0.70, effect: 3, gun: None, weight: 2, found: &[Tools], mass: 1800 },
+ Kind { nouns: &["crowbar", "pry-bar", "spanner"], dmg: Dmg::Blunt, damage: (2, 6), accuracy: 0.80, effect: 35, gun: None, weight: 5, found: &[Tools], mass: 1600 },
+ Kind { nouns: &["bulkhead maul", "sledge", "breaching hammer"], dmg: Dmg::Blunt, damage: (4, 9), accuracy: 0.65, effect: 70, gun: None, weight: 2, found: &[Tools, Armoury], mass: 5000 },
+ Kind { nouns: &["boarding spike", "stiletto", "awl"], dmg: Dmg::Pierce, damage: (2, 5), accuracy: 0.85, effect: 0, gun: None, weight: 3, found: &[Armoury, Crew], mass: 500 },
+ Kind { nouns: &["thermal lance", "plasma cutter", "heat-knife"], dmg: Dmg::Burn, damage: (3, 7), accuracy: 0.75, effect: 2, gun: None, weight: 2, found: &[Tools, Armoury], mass: 2500 },
+ Kind { nouns: &["arc baton", "shock-prod", "stun-maul"], dmg: Dmg::Shock, damage: (2, 4), accuracy: 0.85, effect: 35, gun: None, weight: 3, found: &[Armoury], mass: 1100 },
+ Kind { nouns: &["censer-flail", "chime-staff", "bell-hammer"], dmg: Dmg::Hymn, damage: (2, 5), accuracy: 0.80, effect: 30, gun: None, weight: 2, found: &[Rites], mass: 2200 },
+ Kind { nouns: &["tuning-blade", "psalter's sword", "cantor's knife"], dmg: Dmg::Hymn, damage: (3, 6), accuracy: 0.75, effect: 20, gun: None, weight: 1, found: &[Rites], mass: 1400 },
+ // Guns: `f` to aim and fire.
+ Kind { nouns: &["slug pistol", "service pistol", "hand-cannon"], dmg: Dmg::Pierce, damage: (2, 5), accuracy: 1.0, effect: 0, gun: gun(7, 6.0, 1, 6), weight: 4, found: &[Armoury, Crew], mass: 1100 },
+ Kind { nouns: &["rail rifle", "long-gun", "coilgun"], dmg: Dmg::Pierce, damage: (4, 8), accuracy: 1.0, effect: 1, gun: gun(12, 1.5, 1, 2), weight: 1, found: &[Armoury], mass: 4500 },
+ Kind { nouns: &["scattergun", "blunderbuss", "grapeshot pistol"], dmg: Dmg::Blunt, damage: (1, 3), accuracy: 1.0, effect: 15, gun: gun(5, 16.0, 5, 2), weight: 3, found: &[Armoury, Crew], mass: 3200 },
+ Kind { nouns: &["flamer", "incinerator", "torch-gun"], dmg: Dmg::Burn, damage: (1, 3), accuracy: 1.0, effect: 3, gun: gun(4, 12.0, 3, 3), weight: 2, found: &[Tools, Armoury], mass: 6000 },
+ Kind { nouns: &["arc caster", "spark-thrower", "coil projector"], dmg: Dmg::Shock, damage: (2, 5), accuracy: 1.0, effect: 40, gun: gun(5, 3.0, 1, 3), weight: 2, found: &[Armoury, Tools], mass: 3500 },
+ Kind { nouns: &["needler", "flechette gun", "dart pistol"], dmg: Dmg::Cut, damage: (1, 2), accuracy: 1.0, effect: 1, gun: gun(7, 8.0, 3, 4), weight: 3, found: &[Crew, Armoury], mass: 1300 },
+ Kind { nouns: &["harpoon gun", "bolt-thrower", "spear-gun"], dmg: Dmg::Cut, damage: (3, 7), accuracy: 1.0, effect: 2, gun: gun(6, 4.0, 1, 1), weight: 2, found: &[Tools], mass: 3800 },
+ Kind { nouns: &["psalm-horn", "choir-lantern", "hymnal projector"], dmg: Dmg::Hymn, damage: (2, 6), accuracy: 1.0, effect: 25, gun: gun(6, 9.0, 1, 3), weight: 2, found: &[Rites], mass: 2600 },
+];
+
+/// Base chance of a critical hit.
+const CRIT: f64 = 0.05;
+
+fn kit(title: &'static str, dmg: Dmg, damage: (i32, i32), gun: Option<Gun>, mass: u32) -> Weapon {
+ Weapon {
+ title: title.to_string(),
+ description: format!("your own {title}"),
+ dmg,
+ damage,
+ accuracy: 0.8,
+ crit: CRIT,
+ effect: 0,
+ loaded: gun.map_or(0, |g| g.magazine),
+ gun,
+ kit: true,
+ named: false,
+ condition: Condition::Sound,
+ mass,
+ }
+}
+
+/// The torch you cut your way aboard with. It's also what you hit things with
+/// until you find better.
+pub fn cutting_torch() -> Weapon {
+ kit("cutting torch", Dmg::Burn, (2, 5), None, 2200)
+}
+
+/// For pinning hull plates; it does for rust-mites, too.
+pub fn rivet_gun() -> Weapon {
+ kit("rivet gun", Dmg::Pierce, (1, 3), gun(6, 5.0, 1, 3), 1800)
+}
+
+/// What a weapon's condition did to it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Condition {
+ Worn,
+ Sound,
+ Fine,
+ Relic,
+}
+
+/// A weapon found where `source` things turn up. `better` shifts the odds
+/// toward finer ones: an armoury keeps its stock in better shape than a
+/// galley does.
+pub fn find(rng: &mut Rng, source: Source, better: bool) -> Weapon {
+ let kind = KINDS
+ .choose_weighted(rng, |k| k.weight * if k.found.contains(&source) { 5 } else { 1 })
+ .expect("there are weapon kinds");
+ let roll = rng.random_range(0..100) + if better { 25 } else { 0 };
+ let condition = match roll {
+ ..25 => Condition::Worn,
+ 25..65 => Condition::Sound,
+ 65..94 => Condition::Fine,
+ _ => Condition::Relic,
+ };
+ forge(rng, kind, condition)
+}
+
+/// A weapon for an armourer's rack where `culture` lives. Each people
+/// favours its own sort: Qaari blades and spear-guns, Cant shock and burn
+/// tools, High House duelling pieces in fine condition, Drift guns. A rack
+/// keeps nothing too worn to sell; a `relic` is rare and dear.
+pub fn stocked(rng: &mut Rng, culture: Culture, relic: bool) -> Weapon {
+ let favoured = |k: &Kind| match culture {
+ Culture::Qaari => k.dmg == Dmg::Cut,
+ Culture::Cant => matches!(k.dmg, Dmg::Shock | Dmg::Burn),
+ Culture::Houses => matches!(k.dmg, Dmg::Pierce | Dmg::Hymn),
+ Culture::Drift => k.gun.is_some(),
+ };
+ let kind = KINDS
+ .choose_weighted(rng, |k| k.weight * if favoured(k) { 4 } else { 1 })
+ .expect("there are weapon kinds");
+ let fine = if culture == Culture::Houses { 20 } else { 0 };
+ let condition = match rng.random_range(0..100) + fine {
+ _ if relic => Condition::Relic,
+ ..15 => Condition::Worn,
+ 15..70 => Condition::Sound,
+ _ => Condition::Fine,
+ };
+ forge(rng, kind, condition)
+}
+
+/// What a weapon's worth to an armourer, in scrip: more for more damage, a
+/// surer hit, a longer reach and a stronger extra, and a good deal more for
+/// a fine one or a relic.
+pub fn value(w: &Weapon) -> u32 {
+ let (lo, hi) = w.damage;
+ let pellets = w.gun.map_or(1, |g| g.pellets);
+ let damage = f64::from(lo + hi) / 2.0 * f64::from(pellets);
+ let aim = match w.gun {
+ Some(g) => (0.6 + f64::from(g.range.min(12)) / 30.0) * (1.0 - g.spread.min(45.0) / 60.0),
+ None => w.accuracy,
+ };
+ let n = f64::from(w.effect);
+ let extra = match w.dmg {
+ Dmg::Cut | Dmg::Burn => n * 0.8,
+ Dmg::Pierce => n,
+ Dmg::Blunt => n / 25.0,
+ Dmg::Shock | Dmg::Hymn => n / 20.0,
+ };
+ let power = (damage * aim + extra) * (1.0 + 1.5 * w.crit);
+ let condition = match w.condition {
+ Condition::Worn => 0.8,
+ Condition::Sound => 1.0,
+ Condition::Fine => 1.25,
+ Condition::Relic => 3.0,
+ };
+ (5.0 * power.powf(1.5) * condition).round().max(1.0) as u32
+}
+
+fn forge(rng: &mut Rng, kind: &Kind, condition: Condition) -> Weapon {
+ let noun = pick(kind.nouns, rng);
+ let mut w = Weapon {
+ title: String::new(),
+ description: String::new(),
+ dmg: kind.dmg,
+ damage: kind.damage,
+ accuracy: kind.accuracy,
+ crit: CRIT,
+ effect: kind.effect,
+ gun: kind.gun,
+ loaded: 0,
+ kit: false,
+ named: false,
+ condition,
+ mass: kind.mass,
+ };
+ // No two quite alike.
+ w.damage.1 = (w.damage.1 + rng.random_range(-1..=1)).max(w.damage.0);
+ if !w.is_gun() {
+ w.accuracy += rng.random_range(-0.05..0.05);
+ }
+ if let Some(g) = &mut w.gun {
+ g.spread *= rng.random_range(0.85..1.15);
+ g.spread = (g.spread * 2.0).round() / 2.0;
+ }
+
+ let mut words = Vec::new();
+ match condition {
+ Condition::Worn => {
+ scale(&mut w, 0.75);
+ w.accuracy -= 0.05;
+ words.push(pick(WORN, rng));
+ }
+ Condition::Sound => {}
+ Condition::Fine => words.extend(affixes(rng, &mut w, 1)),
+ Condition::Relic => {
+ scale(&mut w, 1.25);
+ w.accuracy += 0.05;
+ words.extend(affixes(rng, &mut w, 2));
+ }
+ }
+ w.accuracy = w.accuracy.clamp(0.4, 0.98);
+ w.crit = w.crit.min(0.5);
+ w.reload();
+
+ let mut what = words.join(" ");
+ if !what.is_empty() {
+ what.push(' ');
+ }
+ what.push_str(noun);
+ w.description = names::a(&what);
+ if condition == Condition::Relic {
+ w.title = relic_name(rng);
+ w.named = true;
+ } else {
+ w.title = what;
+ }
+ w
+}
+
+/// Scales the damage, keeping it at least 1–1.
+fn scale(w: &mut Weapon, k: f64) {
+ let s = |x: i32| ((f64::from(x) * k).round() as i32).max(1);
+ w.damage = (s(w.damage.0), s(w.damage.1).max(s(w.damage.0)));
+}
+
+/// Up to `n` different affixes that suit the weapon, applied; returns their
+/// words.
+fn affixes(rng: &mut Rng, w: &mut Weapon, n: usize) -> Vec<&'static str> {
+ let mut words = Vec::new();
+ for _ in 0..n {
+ let fits: Vec<&Affix> =
+ AFFIXES.iter().filter(|a| (a.fits)(w) && !words.contains(&a.word)).collect();
+ let Some(affix) = fits.choose(rng) else { break };
+ (affix.apply)(w);
+ words.push(affix.word);
+ }
+ words
+}
+
+struct Affix {
+ word: &'static str,
+ fits: fn(&Weapon) -> bool,
+ apply: fn(&mut Weapon),
+}
+
+fn melee(w: &Weapon) -> bool {
+ !w.is_gun()
+}
+
+fn gun_mut(w: &mut Weapon) -> &mut Gun {
+ w.gun.as_mut().expect("affix only fits guns")
+}
+
+#[rustfmt::skip]
+const AFFIXES: &[Affix] = &[
+ Affix { word: "serrated", fits: |w| w.dmg == Dmg::Cut, apply: |w| w.effect += 1 },
+ Affix { word: "keen", fits: |w| melee(w) && matches!(w.dmg, Dmg::Cut | Dmg::Pierce), apply: |w| w.crit += 0.15 },
+ Affix { word: "heavy", fits: melee, apply: |w| { w.damage.0 += 1; w.damage.1 += 2; w.accuracy -= 0.1 } },
+ Affix { word: "balanced", fits: melee, apply: |w| w.accuracy += 0.1 },
+ Affix { word: "weighted", fits: |w| w.dmg == Dmg::Blunt, apply: |w| w.effect += 20 },
+ Affix { word: "searing", fits: |w| w.dmg == Dmg::Burn, apply: |w| { w.effect += 1; w.damage.1 += 1 } },
+ Affix { word: "overcharged", fits: |w| w.dmg == Dmg::Shock, apply: |w| { w.effect += 20; w.damage.1 += 1 } },
+ Affix { word: "hallowed", fits: |w| w.dmg == Dmg::Hymn, apply: |w| { w.effect += 20; w.damage.0 += 1; w.damage.1 += 1 } },
+ Affix { word: "long-barrelled", fits: |w| w.gun.is_some_and(|g| g.pellets == 1), apply: |w| { let g = gun_mut(w); g.range += 3; g.spread *= 0.6 } },
+ Affix { word: "rifled", fits: |w| w.gun.is_some_and(|g| g.spread > 2.0), apply: |w| gun_mut(w).spread *= 0.7 },
+ Affix { word: "drum-fed", fits: |w| w.gun.is_some(), apply: |w| { let g = gun_mut(w); g.magazine += g.magazine.div_ceil(2) + 1 } },
+ Affix { word: "sawn-off", fits: |w| w.gun.is_some_and(|g| g.pellets > 1), apply: |w| { w.damage.0 += 1; w.damage.1 += 1; let g = gun_mut(w); g.spread *= 1.3; g.range -= 1 } },
+ Affix { word: "tungsten-cored", fits: |w| w.dmg == Dmg::Pierce && w.gun.is_some(), apply: |w| w.effect += 1 },
+ Affix { word: "high-calibre", fits: |w| w.gun.is_some_and(|g| g.pellets == 1), apply: |w| { w.damage.0 += 1; w.damage.1 += 2; let g = gun_mut(w); g.magazine = g.magazine.saturating_sub(1).max(1) } },
+];
+
+/// How a worn weapon looks.
+const WORN: &[&str] = &[
+ "pitted", "battered", "rust-flecked", "taped-up", "bent", "scorched", "chipped", "patched",
+ "grimy", "cracked",
+];
+
+pub(crate) fn relic_name(rng: &mut Rng) -> String {
+ let culture = Culture::ALL[rng.random_range(..Culture::ALL.len())];
+ match rng.random_range(0..5) {
+ 0 | 1 => format!("{}'s {}", culture.word(rng), pick(VIRTUES, rng)),
+ 2 | 3 => format!("the {}", pick(TITLES, rng)),
+ _ => format!("the {} of {}", pick(VIRTUES, rng), culture.place(rng)),
+ }
+}
+
+const VIRTUES: &[&str] = &[
+ "Mercy", "Answer", "Penance", "Tithe", "Benediction", "Reply", "Grief", "Promise", "Wake",
+ "Lullaby", "Verdict", "Vigil", "Absolution", "Welcome", "Errand", "Patience", "Due",
+];
+
+const TITLES: &[&str] = &[
+ "Quiet Argument", "Last Rites", "Patient Reply", "Red Ledger", "Cold Comfort", "Small Mercy",
+ "Iron Vow", "Long Silence", "Bright Answer", "Hungry Saint", "Second Opinion", "Final Offer",
+ "Kind Word", "Open Door", "Ninth Psalm", "Short Sermon", "Closing Hymn", "Widow's Dowry",
+ "Unpaid Debt", "Late Guest",
+];
+
+/// How far a shot strays, in degrees: most of the time near the middle,
+/// never past `spread` either side.
+pub fn stray(rng: &mut Rng, spread: f64) -> f64 {
+ spread * (rng.random_range(0.0..1.0) - rng.random_range(0.0..1.0))
+}
+
+/// The stray at fraction `u` of the way through all strays, smallest first:
+/// `stray` is equally likely to land in any of `n` evenly spaced slices of
+/// these. For working out the odds of a shot without rolling dice.
+pub fn stray_quantile(u: f64, spread: f64) -> f64 {
+ if u < 0.5 {
+ spread * (-1.0 + (2.0 * u).sqrt())
+ } else {
+ spread * (1.0 - (2.0 * (1.0 - u)).sqrt())
+ }
+}
+
+/// The cells a shot from `from` crosses, aimed at the middle of `aim` but
+/// straying `deviation` degrees, out to `range` cells. Doesn't include
+/// `from`, and knows nothing of walls; the caller stops it where it hits.
+pub fn flight(from: Pos, aim: Pos, deviation: f64, range: i32) -> Vec<Pos> {
+ let (dx, dy) = (f64::from(aim.x - from.x), f64::from(aim.y - from.y));
+ if dx == 0.0 && dy == 0.0 {
+ return Vec::new();
+ }
+ let angle = dy.atan2(dx) + deviation.to_radians();
+ let (cos, sin) = (angle.cos(), angle.sin());
+ let (x0, y0) = (f64::from(from.x) + 0.5, f64::from(from.y) + 0.5);
+ // Into every cell whose edge is within `range` and a half of the middle
+ // of `from`, a twentieth of a cell at a time.
+ let steps = (f64::from(range) + 0.5) * 20.0;
+ let mut cells: Vec<Pos> = Vec::new();
+ for i in 1..steps as i32 {
+ let t = f64::from(i) / 20.0;
+ let p = Pos::new((x0 + cos * t).floor() as i32, (y0 + sin * t).floor() as i32);
+ if p != from && cells.last() != Some(&p) {
+ cells.push(p);
+ }
+ }
+ cells
+}
+
+/// A few lines describing `n` weapons found in a run of wrecks, for
+/// `orrery weapons`.
+pub fn catalogue(rng: &mut Rng, n: usize) -> String {
+ let mut out = String::new();
+ let sources = [Tools, Armoury, Crew, Rites];
+ for i in 0..n {
+ let w = find(rng, sources[i % sources.len()], false);
+ let _ = match w.named {
+ true => writeln!(out, "{} ({})", w.name(), w.description),
+ false => writeln!(out, "{}", w.name()),
+ };
+ for line in w.stats() {
+ let _ = writeln!(out, " {line}");
+ }
+ let _ = writeln!(out, " {:.1} kg", f64::from(w.mass) / 1000.0);
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::seed;
+
+ #[test]
+ fn found_weapons_are_sound() {
+ let mut rng = seed::rng(4);
+ let mut titles = std::collections::HashSet::new();
+ for i in 0..2000 {
+ let source = [Tools, Armoury, Crew, Rites][i % 4];
+ let w = find(&mut rng, source, i % 3 == 0);
+ assert!(w.damage.0 >= 1 && w.damage.0 <= w.damage.1, "{w:?}");
+ assert!((0.4..=0.98).contains(&w.accuracy), "{w:?}");
+ assert!(!w.title.is_empty() && !w.description.is_empty());
+ if let Some(g) = w.gun {
+ assert!(g.range >= 3 && g.magazine >= 1 && g.pellets >= 1 && g.spread > 0.0);
+ assert_eq!(w.loaded, g.magazine, "found loaded");
+ }
+ assert!(w.mass > 0);
+ // A save from before weight gets the same weight back.
+ let mut saved = w.clone();
+ saved.mass = 0;
+ saved.mend();
+ assert_eq!(saved.mass, w.mass, "{}", w.description);
+ titles.insert(w.title.clone());
+ }
+ assert!(titles.len() > 300, "only {} different weapons", titles.len());
+ }
+
+ #[test]
+ fn every_kind_of_damage_turns_up() {
+ let mut rng = seed::rng(5);
+ let found: Vec<Dmg> =
+ (0..400).map(|_| find(&mut rng, Source::Armoury, false).dmg).collect();
+ for dmg in Dmg::ALL {
+ assert!(found.contains(&dmg), "never found a {} weapon", dmg.name());
+ }
+ }
+
+ #[test]
+ fn shots_fly_straight_when_they_dont_stray() {
+ let from = Pos::new(2, 2);
+ let path = flight(from, Pos::new(6, 2), 0.0, 4);
+ assert_eq!(path, (3..=6).map(|x| Pos::new(x, 2)).collect::<Vec<_>>());
+ let diagonal = flight(from, Pos::new(5, 5), 0.0, 4);
+ assert_eq!(diagonal[..3], [Pos::new(3, 3), Pos::new(4, 4), Pos::new(5, 5)]);
+ assert!(flight(from, from, 0.0, 5).is_empty());
+ }
+
+ #[test]
+ fn strays_stay_within_the_spread() {
+ let mut rng = seed::rng(6);
+ for _ in 0..1000 {
+ assert!(stray(&mut rng, 8.0).abs() <= 8.0);
+ }
+ assert_eq!(stray_quantile(0.5, 8.0), 0.0);
+ assert!(stray_quantile(0.0, 8.0) <= -7.99 && stray_quantile(1.0, 8.0) >= 7.99);
+ assert!(stray_quantile(0.25, 8.0) < stray_quantile(0.3, 8.0));
+ }
+}
diff --git a/src/dungeon/crawl.rs b/src/dungeon/crawl.rs
index c5196b9..72d8a35 100644
--- a/src/dungeon/crawl.rs
+++ b/src/dungeon/crawl.rs
@@ -1,31 +1,110 @@
//! The rules of a crawl: moving, fighting, picking things up, and what the
//! derelict's inhabitants do each turn. Nothing here draws; the scene turns
//! keys into `Action`s and draws whatever state results.
+//!
+//! You fight with what you've readied: walk into something to hit it with
+//! your blade or club, or fire your gun at anything in range. Every monster
+//! takes each kind of damage its own way (weak to some, shrugging off
+//! others), and a monster's armour comes off every hit but a piercing one.
+//! Yours guards against blows by kind, and wears as it does (see
+//! `armour`). Cuts bleed, fires burn, blows knock back and shocks stun; see
+//! `arms::Dmg`.
+
+use std::collections::BTreeSet;
use rand::RngExt;
+use serde::{Deserialize, Serialize};
+use super::arms::{self, Dmg, Gun, Weapon};
use super::fov::{self, Fov};
use super::ship;
use super::{Map, Pos, Tile};
-use crate::names::Artifact;
+use crate::items::{self, Gear, Item, Slot, Spot, Stack};
use crate::seed::{self, Rng};
/// How far your lamp reaches, in cells.
pub const SIGHT_RADIUS: i32 = 8;
const PLAYER_HP: i32 = 20;
-/// Your cutting torch.
-const PLAYER_DAMAGE: (i32, i32) = (2, 5);
-const PLAYER_HIT: f64 = 0.8;
-const MONSTER_HIT: f64 = 0.7;
-const MEDPATCH_HEAL: i32 = 8;
+pub const MEDPATCH_HEAL: i32 = 8;
const LOG_LENGTH: usize = 50;
+/// Bleeding doesn't get worse than this.
+const MAX_BLEEDING: u32 = 6;
+/// How many evenly spread strays `odds` tries.
+const ODDS_SAMPLES: u32 = 64;
+/// Gunfire wakes anything within this many steps of you.
+const GUNFIRE_CARRIES: u32 = 7;
+
+/// How something takes a kind of damage.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Affinity {
+ /// Half as much again.
+ Weak,
+ Normal,
+ /// Half.
+ Resists,
+ /// None at all.
+ Immune,
+}
+
+impl Affinity {
+ pub fn scale(self, damage: i32) -> i32 {
+ match self {
+ Affinity::Weak => (damage * 3 + 1) / 2,
+ Affinity::Normal => damage,
+ Affinity::Resists => (damage + 1) / 2,
+ Affinity::Immune => 0,
+ }
+ }
+
+ /// As in "the rust-mite is weak to blunt".
+ pub fn phrase(self) -> &'static str {
+ match self {
+ Affinity::Weak => "is weak to",
+ Affinity::Normal => "takes no more or less from",
+ Affinity::Resists => "resists",
+ Affinity::Immune => "is immune to",
+ }
+ }
+}
+
+pub struct Attack {
+ pub dmg: Dmg,
+ pub damage: (i32, i32),
+ /// The chance a blow lands. Shots ignore it; they stray instead.
+ pub hit: f64,
+ /// Bleeding it leaves you with.
+ pub bleed: u32,
+ /// Turns it sets you alight for.
+ pub ignite: u32,
+ /// The chance it knocks you back a cell.
+ pub knock: f64,
+ /// Shoots from where it is instead of closing in.
+ pub gun: Option<Gun>,
+}
+
+const fn bite(dmg: Dmg, damage: (i32, i32), hit: f64, bleed: u32) -> Attack {
+ Attack { dmg, damage, hit, bleed, ignite: 0, knock: 0.0, gun: None }
+}
pub struct MonsterKind {
pub name: &'static str,
pub glyph: char,
pub rgb: (u8, u8, u8),
pub hp: i32,
- pub damage: (i32, i32),
+ /// Taken off every hit except a piercing one, though a hit always does
+ /// at least 1.
+ pub armour: i32,
+ /// Whether cuts make it bleed.
+ pub blood: bool,
+ pub weak: &'static [Dmg],
+ pub resists: &'static [Dmg],
+ pub immune: &'static [Dmg],
+ pub attack: Attack,
+ /// Steps it takes a turn: none for things bolted to the deck.
+ pub speed: u32,
+ /// Lumbers: steps only every other turn, though it hits whenever you're
+ /// beside it.
+ pub slow: bool,
/// As in "the rust-mite bites you".
pub verb: &'static str,
/// How common it is, relative to the others.
@@ -34,12 +113,73 @@ pub struct MonsterKind {
pub unique: bool,
}
+impl MonsterKind {
+ pub fn affinity(&self, dmg: Dmg) -> Affinity {
+ if self.immune.contains(&dmg) {
+ Affinity::Immune
+ } else if self.resists.contains(&dmg) {
+ Affinity::Resists
+ } else if self.weak.contains(&dmg) {
+ Affinity::Weak
+ } else {
+ Affinity::Normal
+ }
+ }
+}
+
#[rustfmt::skip]
pub const MONSTERS: &[MonsterKind] = &[
- MonsterKind { name: "rust-mite", glyph: 'm', rgb: (196, 112, 60), hp: 3, damage: (1, 2), verb: "bites", weight: 5, unique: false },
- MonsterKind { name: "maintenance drone", glyph: 'd', rgb: (150, 175, 200), hp: 6, damage: (1, 3), verb: "zaps", weight: 3, unique: false },
- MonsterKind { name: "hollow crewman", glyph: 'h', rgb: (160, 200, 150), hp: 9, damage: (2, 4), verb: "claws at", weight: 2, unique: false },
- MonsterKind { name: "choir-wraith", glyph: 'W', rgb: (200, 150, 255), hp: 14, damage: (3, 6), verb: "sings into", weight: 1, unique: true },
+ MonsterKind {
+ name: "rust-mite", glyph: 'm', rgb: (196, 112, 60), hp: 3, armour: 1, blood: false,
+ weak: &[Dmg::Blunt, Dmg::Burn], resists: &[Dmg::Cut], immune: &[],
+ attack: bite(Dmg::Pierce, (1, 2), 0.7, 0), speed: 1, slow: false, verb: "bites", weight: 5, unique: false,
+ },
+ MonsterKind {
+ name: "maintenance drone", glyph: 'd', rgb: (150, 175, 200), hp: 6, armour: 1, blood: false,
+ weak: &[Dmg::Shock, Dmg::Blunt], resists: &[Dmg::Cut], immune: &[],
+ attack: bite(Dmg::Shock, (1, 3), 0.7, 0), speed: 1, slow: false, verb: "zaps", weight: 3, unique: false,
+ },
+ MonsterKind {
+ name: "hollow crewman", glyph: 'h', rgb: (160, 200, 150), hp: 9, armour: 0, blood: true,
+ weak: &[Dmg::Cut], resists: &[Dmg::Pierce], immune: &[],
+ attack: bite(Dmg::Cut, (2, 4), 0.7, 1), speed: 1, slow: false, verb: "claws at", weight: 2, unique: false,
+ },
+ MonsterKind {
+ name: "choir-wraith", glyph: 'W', rgb: (200, 150, 255), hp: 14, armour: 0, blood: false,
+ weak: &[Dmg::Hymn, Dmg::Shock], resists: &[Dmg::Cut, Dmg::Blunt], immune: &[Dmg::Pierce],
+ attack: bite(Dmg::Hymn, (3, 6), 0.7, 0), speed: 1, slow: false, verb: "sings into", weight: 1, unique: true,
+ },
+ MonsterKind {
+ name: "bilge-hound", glyph: 'H', rgb: (214, 170, 96), hp: 6, armour: 0, blood: true,
+ weak: &[Dmg::Burn], resists: &[], immune: &[],
+ attack: bite(Dmg::Cut, (1, 3), 0.75, 1), speed: 2, slow: false, verb: "savages", weight: 2, unique: false,
+ },
+ MonsterKind {
+ name: "bulkhead sentry", glyph: 'S', rgb: (226, 96, 72), hp: 8, armour: 2, blood: false,
+ weak: &[Dmg::Shock], resists: &[Dmg::Cut], immune: &[],
+ attack: Attack {
+ dmg: Dmg::Pierce, damage: (1, 2), hit: 1.0, bleed: 0, ignite: 0, knock: 0.0,
+ gun: Some(Gun { range: 7, spread: 6.0, pellets: 1, magazine: 3 }),
+ },
+ speed: 0, slow: false, verb: "shoots", weight: 1, unique: false,
+ },
+ MonsterKind {
+ name: "welder drone", glyph: 'w', rgb: (255, 150, 60), hp: 7, armour: 1, blood: false,
+ weak: &[Dmg::Shock], resists: &[Dmg::Cut], immune: &[Dmg::Burn],
+ attack: Attack {
+ dmg: Dmg::Burn, damage: (1, 2), hit: 1.0, bleed: 0, ignite: 2, knock: 0.0,
+ gun: Some(Gun { range: 4, spread: 10.0, pellets: 1, magazine: 3 }),
+ },
+ speed: 1, slow: false, verb: "torches", weight: 2, unique: false,
+ },
+ MonsterKind {
+ name: "cargo loader", glyph: 'L', rgb: (222, 184, 70), hp: 12, armour: 2, blood: false,
+ weak: &[Dmg::Shock], resists: &[Dmg::Cut], immune: &[],
+ attack: Attack {
+ dmg: Dmg::Blunt, damage: (2, 5), hit: 0.65, bleed: 0, ignite: 0, knock: 0.5, gun: None,
+ },
+ speed: 1, slow: true, verb: "slams", weight: 2, unique: false,
+ },
];
pub struct Monster {
@@ -48,17 +188,44 @@ pub struct Monster {
pub hp: i32,
/// Dormant until it comes into view; then it hunts you.
pub awake: bool,
+ /// What it'll bleed next turn, one less each turn after.
+ pub bleeding: u32,
+ /// Turns it has left alight.
+ pub burning: u32,
+ /// Turns it'll lose.
+ pub stunned: u32,
+ /// Rounds left before it has to reload, if it has a gun.
+ pub loaded: u32,
}
-pub enum Item {
- Artifact(Artifact),
- Salvage { name: &'static str, value: u32 },
- Medpatch,
+impl Monster {
+ pub fn new(kind: &'static MonsterKind, pos: Pos) -> Monster {
+ let loaded = kind.attack.gun.map_or(0, |g| g.magazine);
+ Monster {
+ kind,
+ pos,
+ hp: kind.hp,
+ awake: false,
+ bleeding: 0,
+ burning: 0,
+ stunned: 0,
+ loaded,
+ }
+ }
}
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Loot {
pub pos: Pos,
- pub item: Item,
+ pub stack: Stack,
+ /// Put down on purpose: walking over it doesn't pick it up again.
+ pub left: bool,
+}
+
+impl Loot {
+ pub fn new(pos: Pos, item: Item) -> Loot {
+ Loot { pos, stack: Stack::one(item), left: false }
+ }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -75,9 +242,52 @@ pub enum Action {
Move(i32, i32),
Wait,
UseMedpatch,
+ /// Fire your gun at a cell.
+ Fire(Pos),
+ Reload,
+ /// Ready the weapon at this index in your pack: a gun to fire, or
+ /// anything else to hit things with.
+ 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),
+ /// Put this many of what's at a spot on you down where you stand.
+ /// Takes no time.
+ Drop(Spot, u32),
+ /// Pick up this many of the `n`th thing where you stand (see `here`).
+ Take(usize, u32),
+ /// Pick up everything here there's room for, even what you put down.
+ PickUp,
+}
+
+/// One round's flight, for drawing.
+pub struct Trace {
+ /// Every cell it crossed, in order, ending where it stopped.
+ pub cells: Vec<Pos>,
+ pub dmg: Dmg,
+ /// Whether it ended in something it hit, rather than a wall or the air.
+ pub hit: bool,
+ /// How far behind the turn's first shot it sets off, in cells of
+ /// flight: shots fired in answer wait for the ones before to land.
+ pub delay: u32,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Target {
+ You,
+ Monster(usize),
+}
+
+struct Shot {
+ cells: Vec<Pos>,
+ struck: Vec<Target>,
}
pub struct Crawl {
+ /// What the ship and its dice are made from.
+ pub seed: u64,
/// "the Vexant".
pub name: String,
/// "Cant hauler".
@@ -88,27 +298,131 @@ pub struct Crawl {
pub max_hp: i32,
pub monsters: Vec<Monster>,
pub loot: Vec<Loot>,
- /// What you're carrying. It's only yours once you're back through the airlock.
- pub artifacts: Vec<Artifact>,
- pub scrip: u32,
- pub medpatches: u32,
+ /// What you're carrying. What you found is only yours once you're back
+ /// through the airlock.
+ pub gear: Gear,
+ /// What you'll bleed next turn, one less each turn after.
+ pub bleeding: u32,
+ /// Whatever opened the wound, in case it's the death of you.
+ bled_by: &'static str,
+ /// Turns you'll go on burning, 1–2 a turn.
+ pub burning: u32,
+ /// Whatever set you alight.
+ burned_by: &'static str,
+ /// Knocked into a bulkhead: you lose your next turn.
+ reeling: bool,
+ /// How each kind of monster takes each kind of damage, as far as you've
+ /// found out by hitting them.
+ pub known: BTreeSet<(&'static str, Dmg)>,
+ /// The kinds of what's died since whoever's keeping count last took
+ /// them.
+ pub slain: Vec<&'static str>,
+ /// This turn's shots, yours and theirs, in the order they were fired.
+ pub volley: Vec<Trace>,
pub fov: Fov,
/// Cells you've seen at some point, drawn from memory when out of sight.
seen: Vec<bool>,
/// Messages, oldest first.
pub log: Vec<String>,
+ /// How many messages there have been, however many the log still holds.
+ pub said: u32,
pub turns: u32,
pub outcome: Option<Outcome>,
+ /// Whether you've been told how to ready what you pick up.
+ shown_pack: bool,
/// Dice for fights. Separate from the ship's seed, so the same ship and
/// the same moves always play out the same way.
rng: Rng,
}
+/// A crawl as it stands, for a save: everything that's changed since the
+/// ship was made from its seed. `Crawl::resume` makes the ship again and
+/// puts it all back.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct SavedCrawl {
+ pub seed: u64,
+ /// The wreck's name, to show without making the ship.
+ pub name: String,
+ player: Pos,
+ hp: i32,
+ max_hp: i32,
+ monsters: Vec<SavedMonster>,
+ loot: Vec<Loot>,
+ gear: Gear,
+ #[serde(default)]
+ bleeding: u32,
+ /// The kind of monster that opened your wound.
+ #[serde(default)]
+ bled_by: String,
+ #[serde(default)]
+ burning: u32,
+ /// The kind of monster that set you alight.
+ #[serde(default)]
+ burned_by: String,
+ /// The doors standing open.
+ #[serde(default)]
+ opened: Vec<Pos>,
+ /// Every cell you've seen, in hex, four cells to a digit.
+ #[serde(default)]
+ seen: String,
+ #[serde(default)]
+ log: Vec<String>,
+ #[serde(default)]
+ turns: u32,
+ #[serde(default)]
+ shown_pack: bool,
+}
+
+/// A monster, with its kind by name.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+struct SavedMonster {
+ kind: String,
+ pos: Pos,
+ hp: i32,
+ awake: bool,
+ bleeding: u32,
+ burning: u32,
+ stunned: u32,
+ loaded: u32,
+}
+
+/// Cells as hex, four to a digit, the first in the lowest bit.
+fn to_hex(cells: &[bool]) -> String {
+ cells
+ .chunks(4)
+ .map(|four| {
+ let n = four.iter().enumerate().fold(0, |n, (i, &on)| n | (u32::from(on) << i));
+ char::from_digit(n, 16).unwrap_or('0')
+ })
+ .collect()
+}
+
+/// `len` cells back from hex; any short of that are unseen.
+fn from_hex(text: &str, len: usize) -> Vec<bool> {
+ let mut cells: Vec<bool> = text
+ .chars()
+ .flat_map(|c| {
+ let n = c.to_digit(16).unwrap_or(0);
+ (0..4).map(move |i| n >> i & 1 == 1)
+ })
+ .collect();
+ cells.resize(len, false);
+ cells
+}
+
+/// The name and description ("Cant hauler") of the derelict that
+/// `Crawl::new(seed)` would board, without starting a crawl.
+pub fn describe(seed: u64) -> (String, String) {
+ let ship = ship::generate(seed::derive(seed, "ship", 0));
+ (ship.name, ship.description)
+}
+
impl Crawl {
pub fn new(seed: u64) -> Crawl {
let ship = ship::generate(seed::derive(seed, "ship", 0));
let cells = (ship.map.width * ship.map.height) as usize;
let mut crawl = Crawl {
+ seed,
fov: fov::compute(&ship.map, ship.start, SIGHT_RADIUS),
name: ship.name,
description: ship.description,
@@ -118,13 +432,21 @@ impl Crawl {
max_hp: PLAYER_HP,
monsters: ship.monsters,
loot: ship.loot,
- artifacts: Vec::new(),
- scrip: 0,
- medpatches: 0,
+ gear: Gear::default(),
+ bleeding: 0,
+ bled_by: "",
+ burning: 0,
+ burned_by: "",
+ reeling: false,
+ known: BTreeSet::new(),
+ slain: Vec::new(),
+ volley: Vec::new(),
seen: vec![false; cells],
log: Vec::new(),
+ said: 0,
turns: 0,
outcome: None,
+ shown_pack: false,
rng: seed::rng(seed::derive(seed, "dice", 0)),
};
crawl.say(format!("You board {}, a derelict {}.", crawl.name, crawl.description));
@@ -132,44 +454,193 @@ impl Crawl {
crawl
}
+ /// Everything about this crawl a save needs to bring it back.
+ pub fn saved(&self) -> SavedCrawl {
+ let open = |p: &Pos| self.map.tile(*p) == Tile::Door { open: true };
+ SavedCrawl {
+ seed: self.seed,
+ name: self.name.clone(),
+ player: self.player,
+ hp: self.hp,
+ max_hp: self.max_hp,
+ monsters: self
+ .monsters
+ .iter()
+ .map(|m| SavedMonster {
+ kind: m.kind.name.to_string(),
+ pos: m.pos,
+ hp: m.hp,
+ awake: m.awake,
+ bleeding: m.bleeding,
+ burning: m.burning,
+ stunned: m.stunned,
+ loaded: m.loaded,
+ })
+ .collect(),
+ loot: self.loot.clone(),
+ gear: self.gear.clone(),
+ bleeding: self.bleeding,
+ bled_by: self.bled_by.to_string(),
+ burning: self.burning,
+ burned_by: self.burned_by.to_string(),
+ opened: self.map.positions().filter(open).collect(),
+ seen: to_hex(&self.seen),
+ log: self.log.clone(),
+ turns: self.turns,
+ shown_pack: self.shown_pack,
+ }
+ }
+
+ /// The crawl a save kept, made again: the same ship, with everything
+ /// that had changed aboard it put back. `None` if it no longer fits the
+ /// ship its seed makes now. The dice roll afresh from here, and what
+ /// you know of monsters is the scene's to fill in.
+ pub fn resume(saved: &SavedCrawl) -> Option<Crawl> {
+ let mut crawl = Crawl::new(saved.seed);
+ crawl.log.clear();
+ for &p in &saved.opened {
+ if crawl.map.contains(p) && matches!(crawl.map.tile(p), Tile::Door { .. }) {
+ crawl.map.set(p, Tile::Door { open: true });
+ }
+ }
+ let fits = |p: Pos| crawl.map.contains(p) && crawl.map.tile(p).walkable();
+ if !fits(saved.player) {
+ return None;
+ }
+ let kind = |name: &str| MONSTERS.iter().find(|k| k.name == name);
+ let monsters = saved
+ .monsters
+ .iter()
+ .filter(|m| fits(m.pos) && m.hp > 0)
+ .filter_map(|m| {
+ Some(Monster {
+ kind: kind(&m.kind)?,
+ pos: m.pos,
+ hp: m.hp,
+ awake: m.awake,
+ bleeding: m.bleeding,
+ burning: m.burning,
+ stunned: m.stunned,
+ loaded: m.loaded,
+ })
+ })
+ .collect();
+ let loot =
+ saved.loot.iter().filter(|l| fits(l.pos) && l.stack.count > 0).cloned().collect();
+ crawl.monsters = monsters;
+ crawl.loot = loot;
+ crawl.player = saved.player;
+ crawl.max_hp = saved.max_hp.max(1);
+ crawl.hp = saved.hp.clamp(1, crawl.max_hp);
+ crawl.gear = saved.gear.clone();
+ crawl.gear.mend();
+ crawl.bled_by = kind(&saved.bled_by).map_or("", |k| k.name);
+ crawl.bleeding = if crawl.bled_by.is_empty() { 0 } else { saved.bleeding };
+ crawl.burned_by = kind(&saved.burned_by).map_or("", |k| k.name);
+ crawl.burning = if crawl.burned_by.is_empty() { 0 } else { saved.burning };
+ crawl.seen = from_hex(&saved.seen, crawl.seen.len());
+ crawl.log = saved.log.iter().rev().take(LOG_LENGTH).rev().cloned().collect();
+ crawl.turns = saved.turns;
+ crawl.shown_pack = saved.shown_pack;
+ crawl.rng = seed::rng(seed::derive(saved.seed, "dice", u64::from(saved.turns) + 1));
+ crawl.fov = fov::compute(&crawl.map, crawl.player, SIGHT_RADIUS);
+ for p in crawl.map.positions() {
+ if crawl.fov.contains(p) {
+ let i = crawl.map.index(p);
+ crawl.seen[i] = true;
+ }
+ }
+ Some(crawl)
+ }
+
pub fn seen(&self, p: Pos) -> bool {
self.map.contains(p) && self.seen[self.map.index(p)]
}
pub fn monster_at(&self, p: Pos) -> Option<&Monster> {
- self.monsters.iter().find(|m| m.pos == p)
+ self.monsters.iter().find(|m| m.pos == p && m.hp > 0)
+ }
+
+ fn alive_at(&self, p: Pos) -> Option<usize> {
+ self.monsters.iter().position(|m| m.pos == p && m.hp > 0)
}
pub fn loot_at(&self, p: Pos) -> Option<&Loot> {
self.loot.iter().find(|l| l.pos == p)
}
+ /// What's lying where you stand, as indices into `loot`.
+ pub fn here(&self) -> Vec<usize> {
+ (0..self.loot.len()).filter(|&i| self.loot[i].pos == self.player).collect()
+ }
+
/// The name of where you're standing.
pub fn location(&self) -> &'static str {
self.map.room_at(self.player).map_or("a dark passage", |r| r.name)
}
+ /// What you know of how `kind` takes `dmg`, if you've hit one with it.
+ pub fn knows(&self, kind: &MonsterKind, dmg: Dmg) -> Option<Affinity> {
+ self.known.contains(&(kind.name, dmg)).then(|| kind.affinity(dmg))
+ }
+
/// Takes your turn, then everything else's. Does nothing once the crawl
/// is over, or if the action wouldn't take any time (walking into a wall).
pub fn act(&mut self, action: Action) {
if self.outcome.is_some() {
return;
}
+ self.volley.clear();
let took_time = match action {
Action::Wait => true,
Action::Move(dx, dy) => self.step(dx, dy),
Action::UseMedpatch => self.use_medpatch(),
+ Action::Fire(aim) => self.fire(aim),
+ Action::Reload => self.reload(),
+ Action::Ready(i) => self.outfit(|g| g.ready(i)),
+ Action::Wear(i) => self.outfit(|g| g.wear(i)),
+ Action::TakeOff(slot) => self.outfit(|g| g.take_off(slot)),
+ Action::Eat(i) => self.outfit(|g| g.eat(i)),
+ Action::Drop(spot, count) => {
+ self.put_down(spot, count);
+ false
+ }
+ Action::Take(n, count) => match self.here().get(n) {
+ Some(&i) => self.take(i, count),
+ None => false,
+ },
+ Action::PickUp => self.pick_up(true),
};
+ self.bury();
if !took_time || self.outcome.is_some() {
return;
}
self.turns += 1;
+ self.fester();
+ self.bury();
+ // Where you are now is what things can see; nothing new wakes until
+ // they've all had their turn.
+ self.fov = fov::compute(&self.map, self.player, SIGHT_RADIUS);
self.monsters_act();
+ self.bury();
+ // Knocked into a bulkhead, you lose a turn while they have theirs.
+ if std::mem::take(&mut self.reeling) && self.outcome.is_none() {
+ self.say("You reel, and lose a turn.".into());
+ self.turns += 1;
+ self.fester();
+ self.bury();
+ self.fov = fov::compute(&self.map, self.player, SIGHT_RADIUS);
+ self.monsters_act();
+ self.bury();
+ // Not twice running.
+ self.reeling = false;
+ }
self.look();
}
- fn say(&mut self, message: String) {
+ pub fn say(&mut self, message: String) {
self.log.push(message);
+ self.said += 1;
if self.log.len() > LOG_LENGTH {
self.log.remove(0);
}
@@ -197,10 +668,17 @@ impl Crawl {
}
}
+ /// Clears away the dead. Everything that kills says so as it does.
+ fn bury(&mut self) {
+ let dead = self.monsters.iter().filter(|m| m.hp <= 0).map(|m| m.kind.name);
+ self.slain.extend(dead);
+ self.monsters.retain(|m| m.hp > 0);
+ }
+
fn step(&mut self, dx: i32, dy: i32) -> bool {
let target = self.player.offset(dx, dy);
- if let Some(i) = self.monsters.iter().position(|m| m.pos == target) {
- self.attack(i);
+ if let Some(i) = self.alive_at(target) {
+ self.strike(i);
return true;
}
match self.map.tile(target) {
@@ -216,113 +694,663 @@ impl Crawl {
}
tile if tile.walkable() => {
self.player = target;
- self.pick_up();
+ self.pick_up(false);
true
}
_ => false,
}
}
- fn pick_up(&mut self) {
- while let Some(i) = self.loot.iter().position(|l| l.pos == self.player) {
- match self.loot.remove(i).item {
- Item::Artifact(artifact) => {
- self.say(format!("You find {}.", artifact.unidentified));
- self.artifacts.push(artifact);
- }
- Item::Salvage { name, value } => {
- self.scrip += value;
- self.say(format!("You salvage {name} ({value} ¤)."));
- }
- Item::Medpatch => {
- self.medpatches += 1;
- self.say("You pocket a medpatch.".into());
- }
+ /// Picks up what's here that there's room for: everything, if you
+ /// asked, or else whatever you didn't put down yourself. Says whether
+ /// you took anything.
+ fn pick_up(&mut self, asked: bool) -> bool {
+ let here = self.here();
+ if asked && here.is_empty() {
+ self.say("There's nothing here to pick up.".into());
+ }
+ let mut took = false;
+ // From the last, so taking one doesn't move the rest.
+ for &i in here.iter().rev() {
+ let loot = &self.loot[i];
+ if loot.left && !asked {
+ let name = loot.stack.name();
+ self.say(format!("You left {name} here (g picks it up)."));
+ } else if asked || self.gear.fits(&loot.stack.item) >= loot.stack.count {
+ took |= self.take(i, loot.stack.count);
+ } else {
+ let (name, kg) = (loot.stack.name(), items::kg(loot.stack.mass()));
+ self.say(format!("There's {name} here ({kg} kg): too much to carry (g, i)."));
+ }
+ }
+ took
+ }
+
+ /// Picks up as many as there's room for, up to `count`, of the loot at
+ /// `i`. Says whether you took any.
+ fn take(&mut self, i: usize, count: u32) -> bool {
+ let loot = &self.loot[i];
+ let count = count.min(loot.stack.count).min(self.gear.fits(&loot.stack.item));
+ if count == 0 {
+ let name = loot.stack.name();
+ let room = items::kg(self.gear.room());
+ self.say(format!("You've no room for {name}: only {room} kg to spare."));
+ return false;
+ }
+ let more = count < loot.stack.count;
+ let stack = if more {
+ self.loot[i].stack.count -= count;
+ Stack { item: self.loot[i].stack.item.clone(), count }
+ } else {
+ self.loot.remove(i).stack
+ };
+ let name = stack.name();
+ self.say(match &stack.item {
+ Item::Artifact(a) => format!("You find {}.", a.unidentified),
+ Item::Salvage(s) => {
+ format!("You salvage {name} ({} kg, worth about {} ¤).", items::kg(s.mass), s.value)
}
+ Item::Medpatch => format!("You pocket {name}."),
+ Item::Weapon(w) => format!("You take {name} ({}).", w.damage_text()),
+ Item::Armor(a) => format!("You take {name} ({}).", a.guards_text()),
+ Item::Food(_) => format!("You take {name}."),
+ });
+ if more {
+ self.say("You've no room for the rest.".into());
+ }
+ let how = match stack.item {
+ Item::Weapon(_) => Some("ready it"),
+ Item::Armor(_) => Some("put it on"),
+ _ => None,
+ };
+ self.gear.add(stack);
+ if let Some(how) = how
+ && !self.shown_pack
+ {
+ self.shown_pack = true;
+ self.say(format!("Your pack (i) is where you {how}."));
}
+ true
}
fn use_medpatch(&mut self) -> bool {
- if self.medpatches == 0 {
+ if self.gear.medpatches() == 0 {
self.say("You have no medpatches.".into());
return false;
}
- if self.hp == self.max_hp {
+ if self.hp == self.max_hp && self.bleeding == 0 && self.burning == 0 {
self.say("You're not hurt.".into());
return false;
}
- self.medpatches -= 1;
+ self.gear.take_medpatch();
let healed = MEDPATCH_HEAL.min(self.max_hp - self.hp);
self.hp += healed;
- self.say(format!("The medpatch stings, then numbs (+{healed})."));
+ let stanched = if self.bleeding > 0 { " The bleeding stops." } else { "" };
+ let smothered = if self.burning > 0 { " Its foam smothers the flames." } else { "" };
+ self.bleeding = 0;
+ self.burning = 0;
+ self.say(format!("The medpatch stings, then numbs (+{healed}).{stanched}{smothered}"));
+ true
+ }
+
+ /// Changes what you have ready or wear, or eats something: a turn's
+ /// work if it works, and nothing if it doesn't.
+ fn outfit(&mut self, change: impl FnOnce(&mut Gear) -> Result<String, String>) -> bool {
+ match change(&mut self.gear) {
+ Ok(said) => {
+ self.say(said);
+ true
+ }
+ Err(said) => {
+ self.say(said);
+ false
+ }
+ }
+ }
+
+ fn put_down(&mut self, spot: Spot, count: u32) {
+ match self.gear.remove(spot, count) {
+ Ok(stack) => {
+ self.say(format!("You put down {}.", stack.name()));
+ self.loot.push(Loot { pos: self.player, stack, left: true });
+ }
+ Err(said) => self.say(said),
+ }
+ }
+
+ fn reload(&mut self) -> bool {
+ let w = &mut self.gear.gun;
+ let Some(gun) = w.gun else { return false };
+ let yours = w.yours();
+ if w.loaded == gun.magazine {
+ self.say(format!("{} is loaded.", capitalise(&yours)));
+ return false;
+ }
+ w.reload();
+ self.say(format!("You reload {yours}."));
true
}
- fn attack(&mut self, i: usize) {
- let name = self.monsters[i].kind.name;
+ /// Hits monster `i` with your readied blade or club.
+ fn strike(&mut self, i: usize) {
+ let weapon = self.gear.melee.clone();
self.monsters[i].awake = true;
- if !self.rng.random_bool(PLAYER_HIT) {
- self.say(format!("Your torch misses the {name}."));
+ if !self.rng.random_bool(weapon.accuracy) {
+ let name = self.monsters[i].kind.name;
+ self.say(format!("{} misses the {name}.", capitalise(&weapon.yours())));
return;
}
- let damage = self.rng.random_range(PLAYER_DAMAGE.0..=PLAYER_DAMAGE.1);
- self.monsters[i].hp -= damage;
- if self.monsters[i].hp <= 0 {
- self.monsters.remove(i);
- self.say(format!("You burn the {name} down to slag."));
+ self.wound(i, &weapon, 1, self.player);
+ }
+
+ /// Fires your gun at `aim`. With the magazine empty, reloads instead.
+ fn fire(&mut self, aim: Pos) -> bool {
+ let weapon = self.gear.gun.clone();
+ let Some(gun) = weapon.gun else { return false };
+ if aim == self.player || !self.map.contains(aim) {
+ return false;
+ }
+ if weapon.loaded == 0 {
+ return self.reload();
+ }
+ self.gear.gun.loaded -= 1;
+ let passes = if weapon.dmg == Dmg::Pierce { weapon.effect } else { 0 };
+ let mut hits = vec![0; self.monsters.len()];
+ for _ in 0..gun.pellets {
+ let stray = arms::stray(&mut self.rng, gun.spread);
+ let shot = self.shot(self.player, aim, stray, gun.range, passes);
+ for &t in &shot.struck {
+ if let Target::Monster(i) = t {
+ hits[i] += 1;
+ }
+ }
+ let hit = !shot.struck.is_empty();
+ self.volley.push(Trace { cells: shot.cells, dmg: weapon.dmg, hit, delay: 0 });
+ }
+ if hits.iter().all(|&n| n == 0) {
+ self.say(format!("{} misses.", capitalise(&weapon.yours())));
+ }
+ self.echo();
+ for (i, n) in hits.into_iter().enumerate() {
+ if n > 0 && self.monsters[i].hp > 0 {
+ self.wound(i, &weapon, n, self.player);
+ }
+ }
+ true
+ }
+
+ /// Gunfire carries through the hull: whatever's asleep within earshot
+ /// wakes, and comes looking.
+ fn echo(&mut self) {
+ let dist = self.map.distances_from(self.player);
+ let mut stirred = false;
+ for m in &mut self.monsters {
+ let near = dist[self.map.index(m.pos)].is_some_and(|d| d <= GUNFIRE_CARRIES);
+ if !m.awake && near {
+ m.awake = true;
+ stirred |= !self.fov.contains(m.pos);
+ }
+ }
+ if stirred {
+ self.say("The shot rings through the hull. Something stirs.".into());
+ }
+ }
+
+ /// Where a round from `from` goes, aimed at `aim` but straying
+ /// `stray` degrees: the cells it crosses and what it hits, in order. It
+ /// stops at a wall, or at the first thing it hits after passing
+ /// through `passes` others.
+ fn shot(&self, from: Pos, aim: Pos, stray: f64, range: i32, mut passes: u32) -> Shot {
+ let mut shot = Shot { cells: Vec::new(), struck: Vec::new() };
+ for p in arms::flight(from, aim, stray, range) {
+ shot.cells.push(p);
+ if self.map.tile(p).blocks_sight() {
+ break;
+ }
+ let target = if p == self.player {
+ Some(Target::You)
+ } else {
+ self.alive_at(p).map(Target::Monster)
+ };
+ if let Some(t) = target {
+ shot.struck.push(t);
+ if passes == 0 {
+ break;
+ }
+ passes -= 1;
+ }
+ }
+ shot
+ }
+
+ /// The chance that one round from your gun, aimed at `aim`, hits what's
+ /// there, given what's in the way. `None` if nothing's there.
+ pub fn odds(&self, aim: Pos) -> Option<f64> {
+ let weapon = &self.gear.gun;
+ let gun = weapon.gun?;
+ let i = self.alive_at(aim)?;
+ let passes = if weapon.dmg == Dmg::Pierce { weapon.effect } else { 0 };
+ let hits = (0..ODDS_SAMPLES)
+ .filter(|&k| {
+ let u = (f64::from(k) + 0.5) / f64::from(ODDS_SAMPLES);
+ let stray = arms::stray_quantile(u, gun.spread);
+ self.shot(self.player, aim, stray, gun.range, passes)
+ .struck
+ .contains(&Target::Monster(i))
+ })
+ .count();
+ Some(hits as f64 / f64::from(ODDS_SAMPLES))
+ }
+
+ /// The cells a dead-straight shot at `aim` would cross before a wall
+ /// stops it or it runs out of range.
+ pub fn line_of_fire(&self, aim: Pos) -> Vec<Pos> {
+ let range = self.gear.gun.gun.map_or(0, |g| g.range);
+ self.shot(self.player, aim, 0.0, range, u32::MAX).cells
+ }
+
+ /// Whether `aim` is close enough for your gun to reach.
+ pub fn in_range(&self, aim: Pos) -> bool {
+ self.gear.gun.gun.is_some_and(|g| within(self.player, aim, g.range))
+ }
+
+ /// `hits` blows or rounds from `weapon` land on monster `i`, from the
+ /// direction of `from`. Rolls the damage, does whatever else the weapon
+ /// does, and says so. The dead are left for `bury`.
+ fn wound(&mut self, i: usize, weapon: &Weapon, hits: u32, from: Pos) {
+ let kind = self.monsters[i].kind;
+ let name = kind.name;
+ let affinity = kind.affinity(weapon.dmg);
+ self.monsters[i].awake = true;
+ let (mut total, mut crits) = (0, 0);
+ for _ in 0..hits {
+ let mut d = self.rng.random_range(weapon.damage.0..=weapon.damage.1);
+ if self.rng.random_bool(weapon.crit) {
+ d *= 2;
+ crits += 1;
+ }
+ if affinity != Affinity::Immune {
+ let armour = if weapon.dmg == Dmg::Pierce { 0 } else { kind.armour };
+ total += (affinity.scale(d) - armour).max(1);
+ }
+ }
+
+ let subject = capitalise(&weapon.yours());
+ let verb = if weapon.is_gun() { "hits" } else { weapon.dmg.verb() };
+ let times = match hits {
+ 1 => String::new(),
+ 2 => " twice".into(),
+ n => format!(" {n} times"),
+ };
+ if affinity == Affinity::Immune {
+ self.say(format!("{subject} {verb} the {name}{times}, to no effect."));
} else {
- self.say(format!("You burn the {name} ({damage})."));
+ let mut notes = vec![total.to_string()];
+ if crits > 0 {
+ notes.push("critical".into());
+ }
+ match affinity {
+ Affinity::Weak => notes.push("weak".into()),
+ Affinity::Resists => notes.push("resisted".into()),
+ _ => {}
+ }
+ self.say(format!("{subject} {verb} the {name}{times} ({}).", notes.join(", ")));
+ }
+ if self.known.insert((name, weapon.dmg)) && affinity != Affinity::Normal {
+ self.say(format!("The {name} {} {}.", affinity.phrase(), weapon.dmg.name()));
+ }
+ if total == 0 {
+ return;
+ }
+ self.monsters[i].hp -= total;
+ if self.monsters[i].hp <= 0 {
+ self.say(killed(weapon.dmg, name));
+ return;
+ }
+
+ let chance = f64::from(weapon.effect.min(100)) / 100.0;
+ match weapon.dmg {
+ Dmg::Cut if weapon.effect > 0 && kind.blood => {
+ let m = &mut self.monsters[i];
+ let fresh = m.bleeding == 0;
+ m.bleeding = (m.bleeding + weapon.effect * hits).min(MAX_BLEEDING);
+ if fresh {
+ self.say(format!("The {name} is bleeding."));
+ }
+ }
+ Dmg::Burn if weapon.effect > 0 => {
+ let m = &mut self.monsters[i];
+ let fresh = m.burning == 0;
+ m.burning = m.burning.max(weapon.effect);
+ if fresh {
+ self.say(format!("The {name} catches fire."));
+ }
+ }
+ Dmg::Shock if self.rng.random_bool(chance) => {
+ self.monsters[i].stunned = 1;
+ self.say(format!("The {name} seizes up."));
+ }
+ Dmg::Blunt if self.rng.random_bool(chance) => self.knock_back(i, from),
+ Dmg::Hymn if self.hp < self.max_hp && self.rng.random_bool(chance) => {
+ let eased = self.rng.random_range(1..=2).min(self.max_hp - self.hp);
+ self.hp += eased;
+ self.say(format!("The hymn steadies you (+{eased})."));
+ }
+ _ => {}
}
}
- /// Each awake monster attacks if it's next to you, or else takes one
- /// step along the shortest path toward you, opening doors on the way.
+ /// Knocks monster `i` a cell away from `from`. Into a wall, it staggers.
+ fn knock_back(&mut self, i: usize, from: Pos) {
+ let m = &self.monsters[i];
+ let (name, at) = (m.kind.name, m.pos);
+ let to = at.offset((at.x - from.x).signum(), (at.y - from.y).signum());
+ let tile = self.map.tile(to);
+ if tile.walkable() && to != self.player && self.alive_at(to).is_none() {
+ self.monsters[i].pos = to;
+ self.say(format!("The {name} is knocked back."));
+ } else if tile.blocks_sight() {
+ let slam = self.rng.random_range(1..=2);
+ let m = &mut self.monsters[i];
+ m.hp -= slam;
+ m.stunned = 1;
+ let dead = m.hp <= 0;
+ self.say(format!("The {name} slams into the bulkhead ({slam})."));
+ if dead {
+ self.say(killed(Dmg::Blunt, name));
+ }
+ }
+ }
+
+ /// Each awake monster has its turn: shoots if it has a gun and you in
+ /// its sights, hits you if it's next to you, or else heads toward you
+ /// along the shortest path, opening doors on the way.
fn monsters_act(&mut self) {
let dist = self.map.distances_from(self.player);
for i in 0..self.monsters.len() {
if self.outcome.is_some() {
return;
}
- let monster = &self.monsters[i];
- if !monster.awake {
+ let m = &self.monsters[i];
+ if !m.awake || m.hp <= 0 {
+ continue;
+ }
+ if m.stunned > 0 {
+ self.monsters[i].stunned -= 1;
continue;
}
- if monster.pos.steps_to(self.player) == 1 {
- self.monster_attacks(i);
+ let (kind, pos) = (m.kind, m.pos);
+ if let Some(gun) = kind.attack.gun
+ && within(pos, self.player, gun.range)
+ && self.fov.contains(pos)
+ {
+ self.monster_fires(i, gun);
continue;
}
- let here = dist[self.map.index(monster.pos)].unwrap_or(u32::MAX);
- let next = monster
- .pos
- .neighbours()
- .filter(|&n| self.map.contains(n) && self.monster_at(n).is_none())
- .filter_map(|n| dist[self.map.index(n)].map(|d| (d, n)))
- .filter(|&(d, _)| d < here)
- .min_by_key(|&(d, n)| (d, n.y, n.x));
- if let Some((_, n)) = next {
- if self.map.tile(n) == (Tile::Door { open: false }) {
- self.map.set(n, Tile::Door { open: true });
- } else {
- self.monsters[i].pos = n;
+ for _ in 0..kind.speed {
+ if self.monsters[i].pos.steps_to(self.player) == 1 {
+ self.monster_attacks(i);
+ break;
+ }
+ if kind.slow && self.turns.is_multiple_of(2) {
+ break;
+ }
+ if !self.monster_steps(i, &dist) {
+ break;
}
}
}
}
+ /// Takes one step toward you, or opens a door in the way. Says whether
+ /// it went anywhere.
+ fn monster_steps(&mut self, i: usize, dist: &[Option<u32>]) -> bool {
+ let pos = self.monsters[i].pos;
+ let here = dist[self.map.index(pos)].unwrap_or(u32::MAX);
+ let next = pos
+ .neighbours()
+ .filter(|&n| self.map.contains(n) && self.alive_at(n).is_none())
+ .filter_map(|n| dist[self.map.index(n)].map(|d| (d, n)))
+ .filter(|&(d, _)| d < here)
+ .min_by_key(|&(d, n)| (d, n.y, n.x));
+ let Some((_, n)) = next else { return false };
+ if self.map.tile(n) == (Tile::Door { open: false }) {
+ self.map.set(n, Tile::Door { open: true });
+ } else {
+ self.monsters[i].pos = n;
+ }
+ true
+ }
+
fn monster_attacks(&mut self, i: usize) {
- let kind = self.monsters[i].kind;
- if !self.rng.random_bool(MONSTER_HIT) {
+ let (kind, from) = (self.monsters[i].kind, self.monsters[i].pos);
+ let attack = &kind.attack;
+ if !self.rng.random_bool(attack.hit) {
self.say(format!("The {} misses you.", kind.name));
return;
}
- let damage = self.rng.random_range(kind.damage.0..=kind.damage.1);
+ let damage = self.rng.random_range(attack.damage.0..=attack.damage.1);
+ let (damage, shown, worn) = self.blunted(damage, attack.dmg);
+ self.say(format!("The {} {} you ({shown}).", kind.name, kind.verb));
+ self.hurt(damage, kind.name);
+ self.aftermath(kind, from);
+ for line in worn {
+ self.say(line);
+ }
+ }
+
+ /// What a blow from `kind`, struck from `from`, leaves you with besides
+ /// the hurt: a wound that bleeds unless your armour keeps it closed,
+ /// flames if it burns, a shove if it's heavy.
+ fn aftermath(&mut self, kind: &'static MonsterKind, from: Pos) {
+ let attack = &kind.attack;
+ if self.outcome.is_some() {
+ return;
+ }
+ if attack.bleed > 0 {
+ // Part of the same blow: it wears no more for it.
+ if let Some(a) = self.gear.worn.values().find(|a| a.stanching()) {
+ self.say(format!("{} keeps the wound closed.", capitalise(&a.yours())));
+ } else {
+ if self.bleeding == 0 {
+ self.say("You're bleeding.".into());
+ }
+ self.bleeding = (self.bleeding + attack.bleed).min(MAX_BLEEDING);
+ self.bled_by = kind.name;
+ }
+ }
+ if attack.ignite > 0 {
+ if self.burning == 0 {
+ self.say("You catch alight.".into());
+ }
+ self.burning = self.burning.max(attack.ignite);
+ self.burned_by = kind.name;
+ }
+ if attack.knock > 0.0 && self.rng.random_bool(attack.knock) {
+ self.knocked(from);
+ }
+ }
+
+ /// Knocks you a cell away from `from`. Into a bulkhead, or anything else
+ /// in the way, you reel and lose your next turn.
+ fn knocked(&mut self, from: Pos) {
+ let at = self.player;
+ let to = at.offset((at.x - from.x).signum(), (at.y - from.y).signum());
+ let tile = self.map.tile(to);
+ match self.alive_at(to) {
+ None if tile.walkable() => {
+ self.player = to;
+ self.say("You're knocked back.".into());
+ }
+ Some(j) => {
+ let name = self.monsters[j].kind.name;
+ self.reeling = true;
+ self.say(format!("You're knocked into the {name}."));
+ }
+ None => {
+ self.reeling = true;
+ self.say("You're knocked into the bulkhead.".into());
+ }
+ }
+ }
+
+ fn monster_fires(&mut self, i: usize, gun: Gun) {
+ let (kind, from) = (self.monsters[i].kind, self.monsters[i].pos);
+ if self.monsters[i].loaded == 0 {
+ self.monsters[i].loaded = gun.magazine;
+ self.say(format!("The {} reloads.", kind.name));
+ return;
+ }
+ self.monsters[i].loaded -= 1;
+ let stray = arms::stray(&mut self.rng, gun.spread);
+ let shot = self.shot(from, self.player, stray, gun.range, 0);
+ let delay = self.volley.iter().map(|t| t.delay + t.cells.len() as u32 + 2).max();
+ let attack = &kind.attack;
+ let hit = !shot.struck.is_empty();
+ let struck = shot.struck.first().copied();
+ self.volley.push(Trace {
+ cells: shot.cells,
+ dmg: attack.dmg,
+ hit,
+ delay: delay.unwrap_or(0),
+ });
+ let damage = self.rng.random_range(attack.damage.0..=attack.damage.1);
+ match struck {
+ Some(Target::You) => {
+ let (damage, shown, worn) = self.blunted(damage, attack.dmg);
+ self.say(format!("The {} {} you ({shown}).", kind.name, kind.verb));
+ self.hurt(damage, kind.name);
+ self.aftermath(kind, from);
+ for line in worn {
+ self.say(line);
+ }
+ }
+ Some(Target::Monster(j)) => {
+ let other = self.monsters[j].kind;
+ let affinity = other.affinity(attack.dmg);
+ let damage = if affinity == Affinity::Immune { 0 } else { affinity.scale(damage) };
+ let m = &mut self.monsters[j];
+ m.hp -= damage;
+ m.awake = true;
+ let dead = m.hp <= 0;
+ self.say(format!("The {}'s shot hits the {} ({damage}).", kind.name, other.name));
+ if dead {
+ self.say(format!("The {} goes down.", other.name));
+ }
+ }
+ None => self.say(format!("The {}'s shot goes wide.", kind.name)),
+ }
+ }
+
+ /// What a blow of `damage` does to you through what you're wearing:
+ /// each piece takes off what it guards against `dmg`, though a blow
+ /// always does at least 1, and each piece that softened it wears a
+ /// little. Returns what's left, how to show it ("2", or "pierce 2 - 1 =
+ /// 1"), and anything to say about armour wearing thin or out.
+ fn blunted(&mut self, damage: i32, dmg: Dmg) -> (i32, String, Vec<String>) {
+ let mut left = damage;
+ let mut news = Vec::new();
+ for a in self.gear.worn.values_mut() {
+ let off = a.guard(dmg).min(left - 1);
+ if off > 0 {
+ left -= off;
+ news.extend(a.wear_down());
+ }
+ }
+ let shown = if left < damage {
+ format!("{} {damage} - {} = {left}", dmg.name(), damage - left)
+ } else {
+ damage.to_string()
+ };
+ (left, shown, news)
+ }
+
+ fn hurt(&mut self, damage: i32, by: &'static str) {
self.hp = (self.hp - damage).max(0);
- self.say(format!("The {} {} you ({damage}).", kind.name, kind.verb));
- if self.hp == 0 {
- self.outcome = Some(Outcome::Died { killer: kind.name });
+ if self.hp == 0 && self.outcome.is_none() {
+ self.outcome = Some(Outcome::Died { killer: by });
self.say(format!("You die aboard {}.", self.name));
}
}
+
+ /// After your move each turn, wounds bleed and fires burn, a little
+ /// less each time. Yours too: a wound you take on their move bleeds on
+ /// your next, unless a medpatch stops it first, and flames burn until
+ /// they go out or a medpatch smothers them, your burn guards taking
+ /// their share.
+ fn fester(&mut self) {
+ for i in 0..self.monsters.len() {
+ let m = &mut self.monsters[i];
+ if m.hp <= 0 || (m.bleeding == 0 && m.burning == 0) {
+ continue;
+ }
+ let (mut bled, mut burned) = (0, 0);
+ if m.bleeding > 0 {
+ bled = m.bleeding as i32;
+ m.bleeding -= 1;
+ }
+ if m.burning > 0 {
+ burned = m.kind.affinity(Dmg::Burn).scale(self.rng.random_range(1..=2));
+ m.burning -= 1;
+ }
+ m.hp -= bled + burned;
+ let (name, dead, pos) = (m.kind.name, m.hp <= 0, m.pos);
+ if dead {
+ let how = if burned > 0 { "burns down to slag" } else { "bleeds out" };
+ self.say(format!("The {name} {how}."));
+ } else if self.fov.contains(pos) {
+ let mut what = Vec::new();
+ if bled > 0 {
+ what.push(format!("bleeds ({bled})"));
+ }
+ if burned > 0 {
+ what.push(format!("burns ({burned})"));
+ }
+ self.say(format!("The {name} {}.", what.join(" and ")));
+ }
+ }
+ if self.bleeding > 0 && self.outcome.is_none() {
+ let bled = self.bleeding as i32;
+ self.bleeding -= 1;
+ let clots = if self.bleeding == 0 { " The wound clots." } else { "" };
+ self.say(format!("You bleed ({bled}).{clots}"));
+ self.hurt(bled, self.bled_by);
+ }
+ if self.burning > 0 && self.outcome.is_none() {
+ self.burning -= 1;
+ let heat = self.rng.random_range(1..=2);
+ let (burned, shown, worn) = self.blunted(heat, Dmg::Burn);
+ let out = if self.burning == 0 { " The flames go out." } else { "" };
+ self.say(format!("You burn ({shown}).{out}"));
+ self.hurt(burned, self.burned_by);
+ for line in worn {
+ self.say(line);
+ }
+ }
+ }
+}
+
+/// Whether `b` is within `range` cells of `a`, measured the way sight and
+/// shots are.
+fn within(a: Pos, b: Pos, range: i32) -> bool {
+ let (dx, dy) = (b.x - a.x, b.y - a.y);
+ dx * dx + dy * dy <= range * range + range
+}
+
+fn killed(dmg: Dmg, name: &str) -> String {
+ match dmg {
+ Dmg::Cut => format!("You cut the {name} down."),
+ Dmg::Blunt => format!("You smash the {name} to pieces."),
+ Dmg::Pierce => format!("The {name} drops, holed through."),
+ Dmg::Burn => format!("You burn the {name} down to slag."),
+ Dmg::Shock => format!("The {name} jerks and goes still."),
+ Dmg::Hymn => format!("The {name} falls silent."),
+ }
+}
+
+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)]
@@ -344,7 +1372,71 @@ mod tests {
Action::UseMedpatch,
];
for _ in 0..turns {
- crawl.act(ACTIONS[rng.random_range(..ACTIONS.len())]);
+ let action = match rng.random_range(0..20) {
+ 0 => Action::Reload,
+ 1 => Action::PickUp,
+ 2 => Action::Ready(rng.random_range(0..8)),
+ 3 => {
+ let spot = match rng.random_range(0..4) {
+ 0 => Spot::Melee,
+ 1 => Spot::Gun,
+ 2 => Spot::Worn(Slot::Head),
+ _ => Spot::Pack(rng.random_range(0..8)),
+ };
+ Action::Drop(spot, rng.random_range(1..=3))
+ }
+ 4 | 5 => {
+ let p = crawl.player;
+ Action::Fire(p.offset(rng.random_range(-9..=9), rng.random_range(-9..=9)))
+ }
+ _ => ACTIONS[rng.random_range(..ACTIONS.len())],
+ };
+ crawl.act(action);
+ }
+ }
+
+ /// A crawl in an empty room 30 wide and 9 high, standing at (2, 5)
+ /// with nothing else aboard.
+ fn arena() -> Crawl {
+ let mut crawl = Crawl::new(1);
+ let mut map = Map::new(32, 11, Tile::Hull);
+ for p in map.positions().collect::<Vec<_>>() {
+ if (1..31).contains(&p.x) && (1..10).contains(&p.y) {
+ map.set(p, Tile::Floor);
+ }
+ }
+ crawl.seen = vec![false; (map.width * map.height) as usize];
+ crawl.map = map;
+ crawl.player = Pos::new(2, 5);
+ crawl.monsters.clear();
+ crawl.loot.clear();
+ crawl.look();
+ crawl
+ }
+
+ /// Puts an awake `name` at `pos`.
+ fn spawn(crawl: &mut Crawl, name: &str, pos: Pos) -> usize {
+ let kind = MONSTERS.iter().find(|k| k.name == name).unwrap();
+ let mut m = Monster::new(kind, pos);
+ m.awake = true;
+ crawl.monsters.push(m);
+ crawl.monsters.len() - 1
+ }
+
+ /// A weapon that always lands, doing exactly `damage`.
+ fn sure(dmg: Dmg, damage: i32, effect: u32) -> Weapon {
+ let mut w = arms::cutting_torch();
+ w.kit = false;
+ w.title = "test weapon".into();
+ (w.dmg, w.damage, w.accuracy, w.crit, w.effect) = (dmg, (damage, damage), 1.0, 0.0, effect);
+ w
+ }
+
+ fn wield(crawl: &mut Crawl, w: Weapon) {
+ if w.is_gun() {
+ crawl.gear.gun = w;
+ } else {
+ crawl.gear.melee = w;
}
}
@@ -356,9 +1448,13 @@ mod tests {
assert!(crawl.hp >= 0 && crawl.hp <= crawl.max_hp);
assert!(crawl.map.tile(crawl.player).walkable(), "seed {seed}: player in a wall");
for m in &crawl.monsters {
+ assert!(m.hp > 0, "seed {seed}: a dead {} is still about", m.kind.name);
assert!(crawl.map.tile(m.pos).walkable(), "seed {seed}: {} in a wall", m.kind.name);
assert_ne!(m.pos, crawl.player);
}
+ let g = &crawl.gear;
+ assert!(!g.melee.is_gun() && g.gun.is_gun(), "seed {seed}: the wrong things ready");
+ assert!(g.carried() <= g.capacity, "seed {seed}: carrying too much");
if crawl.outcome.is_none() {
assert!(crawl.fov.contains(crawl.player));
}
@@ -390,7 +1486,9 @@ mod tests {
let mut crawl = Crawl::new(5);
let next_to_player = crawl.player.neighbours().find(|&n| crawl.map.tile(n).walkable());
let pos = next_to_player.unwrap();
- crawl.monsters = vec![Monster { kind: &MONSTERS[0], pos, hp: 3, awake: true }];
+ let mut mite = Monster::new(&MONSTERS[0], pos);
+ mite.awake = true;
+ crawl.monsters = vec![mite];
let (dx, dy) = (pos.x - crawl.player.x, pos.y - crawl.player.y);
for _ in 0..50 {
crawl.act(Action::Move(dx, dy));
@@ -413,4 +1511,430 @@ mod tests {
crawl.act(Action::Move(wall.x - crawl.player.x, wall.y - crawl.player.y));
assert_eq!(crawl.turns, 0);
}
+
+ #[test]
+ fn guns_kill_at_range_and_need_reloading() {
+ let mut crawl = arena();
+ spawn(&mut crawl, "hollow crewman", Pos::new(7, 5));
+ let magazine = crawl.gear.gun.gun.unwrap().magazine;
+ for _ in 0..magazine {
+ let aim = crawl.monsters.first().map_or(Pos::new(7, 5), |m| m.pos);
+ crawl.act(Action::Fire(aim));
+ }
+ assert_eq!(crawl.gear.gun.loaded, 0);
+ assert!(!crawl.volley.is_empty() || crawl.monsters.is_empty());
+ let turns = crawl.turns;
+ crawl.act(Action::Fire(Pos::new(7, 5)));
+ assert_eq!((crawl.turns, crawl.gear.gun.loaded), (turns + 1, magazine), "reloads");
+ for _ in 0..40 {
+ let Some(aim) = crawl.monsters.first().map(|m| m.pos) else { break };
+ crawl.act(Action::Fire(aim));
+ }
+ assert!(crawl.monsters.is_empty(), "the crewman survived 40 rivets");
+ assert!(crawl.known.contains(&("hollow crewman", Dmg::Pierce)));
+ assert!(crawl.log.iter().any(|m| m == "The hollow crewman resists pierce."));
+ }
+
+ #[test]
+ fn some_things_shrug_some_damage_off() {
+ let mut crawl = arena();
+ let i = spawn(&mut crawl, "choir-wraith", Pos::new(5, 5));
+ crawl.monsters[i].stunned = 1000;
+ for _ in 0..4 {
+ crawl.act(Action::Fire(Pos::new(5, 5)));
+ }
+ assert_eq!(crawl.monsters[0].hp, 14, "rivets go through a wraith");
+ assert!(crawl.log.iter().any(|m| m == "The choir-wraith is immune to pierce."));
+ assert_eq!(crawl.knows(crawl.monsters[0].kind, Dmg::Pierce), Some(Affinity::Immune));
+ assert_eq!(crawl.knows(crawl.monsters[0].kind, Dmg::Hymn), None);
+ }
+
+ #[test]
+ fn piercing_rounds_go_through_and_ignore_armour() {
+ let mut crawl = arena();
+ for x in [5, 6] {
+ let i = spawn(&mut crawl, "bulkhead sentry", Pos::new(x, 5));
+ crawl.monsters[i].stunned = 1000;
+ }
+ let mut rail = sure(Dmg::Pierce, 3, 1);
+ rail.gun = Some(Gun { range: 10, spread: 0.0, pellets: 1, magazine: 9 });
+ rail.loaded = 9;
+ wield(&mut crawl, rail);
+ crawl.act(Action::Fire(Pos::new(5, 5)));
+ let hp: Vec<i32> = crawl.monsters.iter().map(|m| m.hp).collect();
+ assert_eq!(hp, [5, 5], "3 through armour 2, and on into the next");
+ }
+
+ #[test]
+ fn armour_takes_its_share() {
+ let mut crawl = arena();
+ let i = spawn(&mut crawl, "bulkhead sentry", Pos::new(3, 5));
+ crawl.monsters[i].stunned = 1000;
+ wield(&mut crawl, sure(Dmg::Blunt, 4, 0));
+ crawl.act(Action::Move(1, 0));
+ assert_eq!(crawl.monsters[0].hp, 6, "4 less armour 2");
+ }
+
+ #[test]
+ fn cuts_bleed_and_bleeding_runs_its_course() {
+ let mut crawl = arena();
+ let i = spawn(&mut crawl, "hollow crewman", Pos::new(3, 5));
+ crawl.monsters[i].stunned = 1000;
+ wield(&mut crawl, sure(Dmg::Cut, 1, 3));
+ crawl.act(Action::Move(1, 0));
+ // 1 weak is 2, then 3 bled at the end of the turn.
+ assert_eq!((crawl.monsters[0].hp, crawl.monsters[0].bleeding), (4, 2));
+ crawl.act(Action::Wait);
+ crawl.act(Action::Wait);
+ crawl.act(Action::Wait);
+ assert_eq!((crawl.monsters[0].hp, crawl.monsters[0].bleeding), (1, 0));
+ }
+
+ #[test]
+ fn machines_dont_bleed() {
+ let mut crawl = arena();
+ let i = spawn(&mut crawl, "maintenance drone", Pos::new(3, 5));
+ crawl.monsters[i].stunned = 1000;
+ wield(&mut crawl, sure(Dmg::Cut, 2, 3));
+ crawl.act(Action::Move(1, 0));
+ assert_eq!(crawl.monsters[0].bleeding, 0);
+ }
+
+ #[test]
+ fn blows_knock_things_back_and_into_walls() {
+ let mut crawl = arena();
+ let i = spawn(&mut crawl, "maintenance drone", Pos::new(3, 5));
+ crawl.monsters[i].stunned = 1000;
+ wield(&mut crawl, sure(Dmg::Blunt, 1, 100));
+ crawl.act(Action::Move(1, 0));
+ assert_eq!(crawl.monsters[0].pos, Pos::new(4, 5));
+
+ crawl.player = Pos::new(29, 5);
+ crawl.monsters[0].pos = Pos::new(30, 5);
+ let hp = crawl.monsters[0].hp;
+ crawl.act(Action::Move(1, 0));
+ assert_eq!(crawl.monsters[0].pos, Pos::new(30, 5), "nowhere to go");
+ assert!(crawl.monsters[0].hp < hp - 1, "it hit the wall too");
+ assert!(crawl.log.iter().any(|m| m.contains("slams into the bulkhead")));
+ }
+
+ #[test]
+ fn shocks_stun() {
+ let mut crawl = arena();
+ spawn(&mut crawl, "bilge-hound", Pos::new(3, 5));
+ crawl.hp = 100;
+ crawl.max_hp = 100;
+ wield(&mut crawl, sure(Dmg::Shock, 1, 100));
+ crawl.act(Action::Move(1, 0));
+ assert!(!crawl.log.iter().any(|m| m.contains("hound savages") || m.contains("misses you")));
+ assert!(crawl.log.iter().any(|m| m.contains("seizes up")));
+ }
+
+ #[test]
+ fn fire_burns_on() {
+ let mut crawl = arena();
+ let i = spawn(&mut crawl, "bilge-hound", Pos::new(3, 5));
+ crawl.monsters[i].stunned = 1000;
+ wield(&mut crawl, sure(Dmg::Burn, 1, 2));
+ crawl.act(Action::Move(1, 0));
+ // 1 weak is 2, then 1–2 weak is 2–3 for the burning.
+ let hp = crawl.monsters[0].hp;
+ assert!((1..=2).contains(&hp), "hp {hp}");
+ assert_eq!(crawl.monsters[0].burning, 1);
+ }
+
+ #[test]
+ fn claws_make_you_bleed_and_medpatches_stop_it() {
+ let mut crawl = arena();
+ spawn(&mut crawl, "hollow crewman", Pos::new(3, 5));
+ crawl.hp = 1000;
+ crawl.max_hp = 1000;
+ for _ in 0..20 {
+ crawl.act(Action::Wait);
+ if crawl.bleeding > 0 {
+ break;
+ }
+ }
+ assert!(crawl.bleeding > 0, "twenty turns of claws and not a scratch");
+ crawl.monsters.clear();
+ crawl.gear.add(Stack::one(Item::Medpatch));
+ crawl.act(Action::UseMedpatch);
+ assert_eq!(crawl.bleeding, 0);
+ }
+
+ #[test]
+ fn sentries_shoot_from_where_they_stand() {
+ let mut crawl = arena();
+ spawn(&mut crawl, "bulkhead sentry", Pos::new(8, 5));
+ crawl.hp = 1000;
+ crawl.max_hp = 1000;
+ for _ in 0..10 {
+ crawl.act(Action::Wait);
+ }
+ assert_eq!(crawl.monsters[0].pos, Pos::new(8, 5));
+ assert!(crawl.hp < 1000, "ten turns under fire and not a scratch");
+ assert!(crawl.volley.iter().any(|t| t.cells.contains(&crawl.player)));
+ }
+
+ #[test]
+ fn hounds_run_two_steps_a_turn() {
+ let mut crawl = arena();
+ spawn(&mut crawl, "bilge-hound", Pos::new(12, 5));
+ crawl.act(Action::Wait);
+ assert_eq!(crawl.monsters[0].pos.steps_to(crawl.player), 8);
+ }
+
+ #[test]
+ fn the_odds_fall_off_with_distance() {
+ let mut pistol = sure(Dmg::Pierce, 1, 0);
+ pistol.gun = Some(Gun { range: 20, spread: 10.0, pellets: 1, magazine: 6 });
+ let odds_at = |x: i32| {
+ let mut crawl = arena();
+ wield(&mut crawl, pistol.clone());
+ spawn(&mut crawl, "rust-mite", Pos::new(x, 5));
+ assert!(crawl.odds(Pos::new(x, 6)).is_none(), "nothing there");
+ crawl.odds(Pos::new(x, 5)).unwrap()
+ };
+ let odds = [3, 6, 12, 20].map(odds_at);
+ assert_eq!(odds[0], 1.0, "point blank");
+ assert!(odds.windows(2).all(|w| w[0] > w[1]), "{odds:?}");
+
+ // Something in the way takes the hit.
+ let mut crawl = arena();
+ spawn(&mut crawl, "rust-mite", Pos::new(4, 5));
+ spawn(&mut crawl, "rust-mite", Pos::new(6, 5));
+ assert!(crawl.odds(Pos::new(6, 5)).unwrap() < 0.5);
+ }
+
+ #[test]
+ fn you_carry_only_what_you_have_room_for() {
+ let mut crawl = arena();
+ let mut rng = seed::rng(1);
+ for _ in 0..12 {
+ let mut w = arms::find(&mut rng, arms::Source::Armoury, false);
+ w.mass = 4000;
+ crawl.loot.push(Loot::new(Pos::new(3, 5), Item::Weapon(w)));
+ }
+ crawl.act(Action::Move(1, 0));
+ let room = crawl.gear.capacity - Gear::default().carried();
+ let fit = (room / 4000) as usize;
+ assert_eq!(crawl.loot.len(), 12 - fit);
+ assert!(crawl.gear.carried() <= crawl.gear.capacity);
+ assert!(crawl.log.iter().any(|m| m.contains("too much to carry")));
+ crawl.act(Action::Take(0, 1));
+ assert_eq!(crawl.loot.len(), 12 - fit, "still no room");
+ assert!(crawl.log.last().unwrap().contains("no room"));
+ }
+
+ #[test]
+ fn stacks_come_and_go_by_the_count() {
+ let mut crawl = arena();
+ crawl.gear.add(Stack { item: Item::Medpatch, count: 5 });
+ crawl.act(Action::Drop(Spot::Pack(0), 2));
+ assert_eq!(crawl.gear.medpatches(), 3);
+ assert_eq!(crawl.loot[0].stack.count, 2);
+ crawl.act(Action::Take(0, 1));
+ assert_eq!(crawl.gear.medpatches(), 4);
+ assert_eq!(crawl.loot[0].stack.count, 1);
+ assert_eq!(crawl.turns, 1, "dropping is free; picking up takes a turn");
+ }
+
+ #[test]
+ fn what_you_put_down_stays_down() {
+ let mut crawl = arena();
+ wield(&mut crawl, sure(Dmg::Cut, 3, 0));
+ crawl.gear.add(Stack::one(Item::Weapon(arms::cutting_torch())));
+ crawl.act(Action::Drop(Spot::Melee, 1));
+ assert_eq!(crawl.turns, 0, "dropping takes no time");
+ assert_eq!(crawl.gear.melee.title, "cutting torch", "back to the torch");
+ assert!(crawl.gear.pack.is_empty());
+ crawl.act(Action::Move(1, 0));
+ crawl.act(Action::Move(-1, 0));
+ assert!(crawl.gear.pack.is_empty(), "walking over it leaves it");
+ crawl.act(Action::PickUp);
+ assert_eq!(crawl.gear.pack.len(), 1);
+
+ crawl.act(Action::Drop(Spot::Gun, 1));
+ assert!(crawl.gear.gun.kit, "with nothing else to shoot with, you keep it");
+ assert!(crawl.log.last().unwrap().contains("nothing to shoot with"));
+ }
+
+ #[test]
+ fn readying_takes_a_turn_and_picks_the_right_hand() {
+ let mut crawl = arena();
+ crawl.gear.add(Stack::one(Item::Weapon(sure(Dmg::Cut, 3, 0))));
+ let mut gun = sure(Dmg::Pierce, 2, 0);
+ gun.gun = Some(Gun { range: 6, spread: 4.0, pellets: 1, magazine: 3 });
+ crawl.gear.add(Stack::one(Item::Weapon(gun)));
+ crawl.act(Action::Ready(0));
+ assert_eq!(crawl.gear.melee.title, "test weapon");
+ // The torch went where the blade was; the gun is now second.
+ crawl.act(Action::Ready(0));
+ assert!(crawl.gear.gun.is_gun() && !crawl.gear.gun.kit);
+ assert_eq!(crawl.turns, 2);
+ crawl.act(Action::Ready(9));
+ assert_eq!(crawl.turns, 2, "nothing there");
+ }
+
+ /// A piece of armour on your body guarding `guards`, with `durability`
+ /// of 10.
+ fn plate(guards: &[(Dmg, i32)], durability: u32, stanches: bool) -> items::Armor {
+ items::Armor {
+ title: "test plate".into(),
+ description: String::new(),
+ slot: Slot::Body,
+ guards: guards.iter().copied().collect(),
+ stanches,
+ durability,
+ max_durability: 10,
+ condition: arms::Condition::Sound,
+ named: false,
+ pair: false,
+ mass: 1,
+ }
+ }
+
+ /// What the armour you wear on your body has left.
+ fn left(crawl: &Crawl) -> u32 {
+ crawl.gear.worn[&Slot::Body].durability
+ }
+
+ #[test]
+ fn armour_takes_its_share_of_blows_of_its_kind_and_wears() {
+ let mut crawl = arena();
+ crawl.gear.worn.insert(Slot::Body, plate(&[(Dmg::Shock, 100)], 10, false));
+ spawn(&mut crawl, "maintenance drone", Pos::new(3, 5));
+ for _ in 0..10 {
+ crawl.act(Action::Wait);
+ }
+ let blows = crawl.log.iter().filter(|l| l.contains("zaps you")).count() as i32;
+ let softened = crawl.log.iter().filter(|l| l.contains("zaps you (shock")).count() as u32;
+ assert!(blows > 0 && crawl.hp == crawl.max_hp - blows, "a point a blow: {:?}", crawl.log);
+ assert!(softened > 0, "{:?}", crawl.log);
+ assert_eq!(left(&crawl), 10 - softened, "a point of wear for each blow it softened");
+
+ // Against another kind of blow it does nothing, and doesn't wear.
+ let mut crawl = arena();
+ crawl.gear.worn.insert(Slot::Body, plate(&[(Dmg::Shock, 100)], 10, false));
+ spawn(&mut crawl, "bilge-hound", Pos::new(3, 5));
+ for _ in 0..4 {
+ crawl.act(Action::Wait);
+ }
+ assert!(crawl.log.iter().any(|l| l.contains("savages you (")));
+ assert!(!crawl.log.iter().any(|l| l.contains("savages you (cut")));
+ assert_eq!(left(&crawl), 10);
+ }
+
+ #[test]
+ fn worn_out_armour_guards_nothing_but_stays_on() {
+ let mut crawl = arena();
+ crawl.gear.worn.insert(Slot::Body, plate(&[(Dmg::Shock, 100)], 1, false));
+ spawn(&mut crawl, "maintenance drone", Pos::new(3, 5));
+ for _ in 0..20 {
+ crawl.act(Action::Wait);
+ if left(&crawl) == 0 {
+ break;
+ }
+ }
+ assert!(crawl.log.iter().any(|l| l.contains("worn out")), "{:?}", crawl.log);
+ assert!(crawl.gear.worn.contains_key(&Slot::Body));
+ assert_eq!(crawl.gear.guard(Dmg::Shock), 0);
+ }
+
+ #[test]
+ fn welders_set_you_alight_from_a_few_cells_off() {
+ let mut crawl = arena();
+ spawn(&mut crawl, "welder drone", Pos::new(5, 5));
+ for _ in 0..20 {
+ crawl.act(Action::Wait);
+ if crawl.burning > 0 {
+ break;
+ }
+ }
+ assert_eq!(crawl.burning, 2, "{:?}", crawl.log);
+ assert!(crawl.log.iter().any(|l| l.starts_with("The welder drone torches you (")));
+ assert!(crawl.log.iter().any(|l| l == "You catch alight."));
+ assert_eq!(crawl.monsters[0].pos, Pos::new(5, 5), "it sprays from where it is");
+
+ // It burns on after, until it goes out or a medpatch smothers it.
+ crawl.monsters.clear();
+ let hp = crawl.hp;
+ crawl.act(Action::Wait);
+ assert!(crawl.hp < hp && crawl.log.last().unwrap().starts_with("You burn ("));
+ assert_eq!(crawl.burning, 1);
+ crawl.gear.add(Stack::one(Item::Medpatch));
+ crawl.act(Action::UseMedpatch);
+ assert_eq!(crawl.burning, 0);
+ assert!(crawl.log.iter().any(|l| l.contains("smothers the flames")));
+ }
+
+ #[test]
+ fn burn_guards_take_their_share_of_the_flames() {
+ let mut crawl = arena();
+ crawl.gear.worn.insert(Slot::Body, plate(&[(Dmg::Burn, 100)], 10, false));
+ crawl.burning = 6;
+ crawl.burned_by = "welder drone";
+ let hp = crawl.hp;
+ for _ in 0..6 {
+ crawl.act(Action::Wait);
+ }
+ assert_eq!(crawl.hp, hp - 6, "1 a turn: {:?}", crawl.log);
+ assert!(crawl.log.last().unwrap().ends_with("The flames go out."));
+ let softened = crawl.log.iter().filter(|l| l.starts_with("You burn (burn 2")).count();
+ assert_eq!(left(&crawl), 10 - softened as u32, "worn by every tick it softened");
+ }
+
+ #[test]
+ fn loaders_lumber_and_knock_you_about() {
+ let mut crawl = arena();
+ let i = spawn(&mut crawl, "cargo loader", Pos::new(8, 5));
+ for _ in 0..4 {
+ crawl.act(Action::Wait);
+ }
+ assert_eq!(crawl.monsters[i].pos.x, 6, "a step every other turn");
+
+ // Beside you, it hits every turn, and knocks you back to the wall.
+ crawl.monsters[i].pos = Pos::new(3, 5);
+ (crawl.hp, crawl.max_hp) = (1000, 1000);
+ let (mut knocked, mut reeled) = (false, false);
+ for _ in 0..40 {
+ let turns = crawl.turns;
+ crawl.act(Action::Wait);
+ knocked |= crawl.log.iter().any(|l| l == "You're knocked back.");
+ if crawl.log.iter().any(|l| l.contains("lose a turn")) {
+ reeled = true;
+ assert!(crawl.log.iter().any(|l| l == "You're knocked into the bulkhead."));
+ assert_eq!(crawl.turns, turns + 2, "you lose a turn while it has another");
+ break;
+ }
+ }
+ assert!(knocked && reeled, "{:?}", crawl.log);
+ assert_eq!(crawl.player, Pos::new(1, 5), "up against the hull");
+ assert!(crawl.log.iter().any(|l| l.starts_with("The cargo loader slams you (")));
+ }
+
+ #[test]
+ fn being_alight_is_saved() {
+ let mut crawl = Crawl::new(5);
+ (crawl.burning, crawl.burned_by) = (2, "welder drone");
+ let back = Crawl::resume(&crawl.saved()).unwrap();
+ assert_eq!((back.burning, back.burned_by), (2, "welder drone"));
+ let mut saved = crawl.saved();
+ saved.burned_by = "something long gone".into();
+ assert_eq!(Crawl::resume(&saved).unwrap().burning, 0);
+ }
+
+ #[test]
+ fn armour_that_stanches_keeps_cuts_from_bleeding() {
+ let mut crawl = arena();
+ crawl.gear.worn.insert(Slot::Body, plate(&[], 10, true));
+ spawn(&mut crawl, "bilge-hound", Pos::new(3, 5));
+ for _ in 0..6 {
+ crawl.act(Action::Wait);
+ }
+ assert!(crawl.hp < crawl.max_hp, "the bites still land");
+ assert!(crawl.log.iter().any(|l| l.contains("keeps the wound closed")));
+ assert_eq!(crawl.bleeding, 0);
+ assert_eq!(left(&crawl), 10, "it softened no blow, so it didn't wear");
+ }
}
diff --git a/src/dungeon/mod.rs b/src/dungeon/mod.rs
index d3227a5..baeb0fe 100644
--- a/src/dungeon/mod.rs
+++ b/src/dungeon/mod.rs
@@ -1,11 +1,15 @@
//! Turn-based dungeons: the map, field of view, derelict-ship generation,
//! and the rules of a crawl. Drawing lives in `scenes::derelict`.
+pub mod armour;
+pub mod arms;
pub mod crawl;
pub mod fov;
pub mod ship;
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Pos {
pub x: i32,
pub y: i32,
@@ -111,6 +115,14 @@ impl Map {
self.tiles[i] = tile;
}
+ /// Which room `p` is in, to tell when you've gone into another.
+ pub fn room_index(&self, p: Pos) -> Option<u16> {
+ if !self.contains(p) {
+ return None;
+ }
+ self.room_of[self.index(p)]
+ }
+
pub fn room_at(&self, p: Pos) -> Option<&Room> {
if !self.contains(p) {
return None;
diff --git a/src/dungeon/ship.rs b/src/dungeon/ship.rs
index dc9b0b8..d845376 100644
--- a/src/dungeon/ship.rs
+++ b/src/dungeon/ship.rs
@@ -25,8 +25,11 @@
use rand::RngExt;
use rand::seq::IndexedRandom;
-use super::crawl::{Item, Loot, MONSTERS, Monster};
+use super::armour;
+use super::arms::{self, Source};
+use super::crawl::{Loot, MONSTERS, Monster};
use super::{Map, Pos, Tile};
+use crate::items::{Item, Salvage};
use crate::names::{self, Culture};
use crate::seed::{self, Rng};
@@ -52,42 +55,65 @@ struct RoomKind {
salvage: (u32, u32),
/// Chance of a medpatch.
medpatch: f64,
+ /// Chance of a weapon; at 1, there are one or two, and in better shape.
+ arms: f64,
+ /// What sort of weapon it'd be, or armour.
+ source: Source,
+ /// Chance of a piece of armour; at 1, one or two, in better shape.
+ armour: f64,
}
-const ENGINE_ROOM: RoomKind =
- RoomKind { name: "the engine room", weight: 0, artifact: 0.05, salvage: (2, 3), medpatch: 0.1 };
-const BRIDGE: RoomKind =
- RoomKind { name: "the bridge", weight: 0, artifact: 0.6, salvage: (0, 2), medpatch: 0.1 };
+const ENGINE_ROOM: RoomKind = RoomKind {
+ name: "the engine room",
+ weight: 0,
+ artifact: 0.05,
+ salvage: (2, 3),
+ medpatch: 0.1,
+ arms: 0.3,
+ source: Source::Tools,
+ armour: 0.15,
+};
+const BRIDGE: RoomKind = RoomKind {
+ name: "the bridge",
+ weight: 0,
+ artifact: 0.6,
+ salvage: (0, 2),
+ medpatch: 0.1,
+ arms: 0.3,
+ source: Source::Armoury,
+ armour: 0.2,
+};
#[rustfmt::skip]
const ROOMS: &[RoomKind] = &[
- RoomKind { name: "a cargo hold", weight: 4, artifact: 0.05, salvage: (2, 4), medpatch: 0.05 },
- RoomKind { name: "the crew berths", weight: 3, artifact: 0.1, salvage: (0, 1), medpatch: 0.25 },
- RoomKind { name: "the galley", weight: 2, artifact: 0.02, salvage: (0, 2), medpatch: 0.1 },
- RoomKind { name: "the med-bay", weight: 1, artifact: 0.05, salvage: (0, 1), medpatch: 1.0 },
- RoomKind { name: "a machine shop", weight: 2, artifact: 0.05, salvage: (1, 3), medpatch: 0.0 },
- RoomKind { name: "the armoury", weight: 1, artifact: 0.15, salvage: (1, 2), medpatch: 0.3 },
- RoomKind { name: "a cryo-vault", weight: 1, artifact: 0.3, salvage: (0, 1), medpatch: 0.2 },
- RoomKind { name: "the chapel", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0 },
- RoomKind { name: "the reliquary", weight: 1, artifact: 0.9, salvage: (0, 0), medpatch: 0.0 },
- RoomKind { name: "the archive", weight: 1, artifact: 0.4, salvage: (0, 1), medpatch: 0.0 },
- RoomKind { name: "the navigator's cell", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0 },
- RoomKind { name: "an observation blister", weight: 1, artifact: 0.1, salvage: (0, 1), medpatch: 0.0 },
+ RoomKind { name: "a cargo hold", weight: 4, artifact: 0.05, salvage: (2, 4), medpatch: 0.05, arms: 0.2, source: Source::Tools, armour: 0.1 },
+ RoomKind { name: "the crew berths", weight: 3, artifact: 0.1, salvage: (0, 1), medpatch: 0.25, arms: 0.3, source: Source::Crew, armour: 0.4 },
+ RoomKind { name: "the galley", weight: 2, artifact: 0.02, salvage: (0, 2), medpatch: 0.1, arms: 0.2, source: Source::Crew, armour: 0.05 },
+ RoomKind { name: "the med-bay", weight: 1, artifact: 0.05, salvage: (0, 1), medpatch: 1.0, arms: 0.1, source: Source::Tools, armour: 0.05 },
+ RoomKind { name: "a machine shop", weight: 2, artifact: 0.05, salvage: (1, 3), medpatch: 0.0, arms: 0.5, source: Source::Tools, armour: 0.2 },
+ RoomKind { name: "the armoury", weight: 1, artifact: 0.15, salvage: (1, 2), medpatch: 0.3, arms: 1.0, source: Source::Armoury, armour: 1.0 },
+ RoomKind { name: "a cryo-vault", weight: 1, artifact: 0.3, salvage: (0, 1), medpatch: 0.2, arms: 0.15, source: Source::Crew, armour: 0.15 },
+ RoomKind { name: "the chapel", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0, arms: 0.4, source: Source::Rites, armour: 0.35 },
+ RoomKind { name: "the reliquary", weight: 1, artifact: 0.9, salvage: (0, 0), medpatch: 0.0, arms: 0.5, source: Source::Rites, armour: 0.3 },
+ RoomKind { name: "the archive", weight: 1, artifact: 0.4, salvage: (0, 1), medpatch: 0.0, arms: 0.1, source: Source::Rites, armour: 0.05 },
+ RoomKind { name: "the navigator's cell", weight: 1, artifact: 0.5, salvage: (0, 1), medpatch: 0.0, arms: 0.2, source: Source::Crew, armour: 0.15 },
+ RoomKind { name: "an observation blister", weight: 1, artifact: 0.1, salvage: (0, 1), medpatch: 0.0, arms: 0.15, source: Source::Armoury, armour: 0.1 },
];
-/// Salvage and its base value; each find varies by ±30%.
-const SALVAGE: &[(&str, u32)] = &[
- ("a coil of superconductor", 30),
- ("a cracked reactor baffle", 45),
- ("a crate of ration bricks", 10),
- ("an intact navigation core", 90),
- ("a spool of wiring", 15),
- ("a stack of hull plates", 25),
- ("a sealed canister of coolant", 20),
- ("a working servo", 35),
- ("a box of votive candles", 8),
- ("a cantor's data-slate", 40),
- ("a half-charged fuel cell", 50),
+/// Salvage: what it's called, its base value, and what it weighs in grams.
+/// Each find's value varies by ±30%.
+const SALVAGE: &[(&str, u32, u32)] = &[
+ ("coil of superconductor", 30, 6000),
+ ("cracked reactor baffle", 45, 9000),
+ ("crate of ration bricks", 10, 8000),
+ ("intact navigation core", 90, 5000),
+ ("spool of wiring", 15, 3000),
+ ("stack of hull plates", 25, 12000),
+ ("sealed canister of coolant", 20, 7000),
+ ("working servo", 35, 4000),
+ ("box of votive candles", 8, 2000),
+ ("cantor's data-slate", 40, 500),
+ ("half-charged fuel cell", 50, 6000),
];
/// A room as generated, before its contents are placed.
@@ -146,13 +172,14 @@ pub fn generate(seed: u64) -> Derelict {
let kind = plan.kind;
if rng.random_bool(kind.artifact) {
let pos = *cells.choose(&mut rng).unwrap();
- loot.push(Loot { pos, item: Item::Artifact(names::artifact(&mut rng)) });
+ loot.push(Loot::new(pos, Item::Artifact(names::artifact(&mut rng))));
}
for _ in 0..rng.random_range(kind.salvage.0..=kind.salvage.1) {
let pos = *cells.choose(&mut rng).unwrap();
- let &(name, base) = SALVAGE.choose(&mut rng).unwrap();
+ let &(title, base, mass) = SALVAGE.choose(&mut rng).unwrap();
let value = (f64::from(base) * rng.random_range(0.7..1.3)).round() as u32;
- loot.push(Loot { pos, item: Item::Salvage { name, value } });
+ let salvage = Salvage { title: title.into(), value, mass };
+ loot.push(Loot::new(pos, Item::Salvage(salvage)));
}
let medpatches = if kind.medpatch >= 1.0 {
rng.random_range(1..=2)
@@ -160,18 +187,18 @@ pub fn generate(seed: u64) -> Derelict {
u32::from(rng.random_bool(kind.medpatch))
};
for _ in 0..medpatches {
- loot.push(Loot { pos: *cells.choose(&mut rng).unwrap(), item: Item::Medpatch });
+ loot.push(Loot::new(*cells.choose(&mut rng).unwrap(), Item::Medpatch));
}
}
// Every derelict is worth boarding: if no room rolled an artifact, put
// one as far from the airlock as the ship goes.
- if !loot.iter().any(|l| matches!(l.item, Item::Artifact(_))) {
+ if !loot.iter().any(|l| matches!(l.stack.item, Item::Artifact(_))) {
let farthest = map
.positions()
.filter(|&p| free(&map, p))
.max_by_key(|&p| (dist[map.index(p)], p.x, p.y))
.expect("a derelict always has floor");
- loot.push(Loot { pos: farthest, item: Item::Artifact(names::artifact(&mut rng)) });
+ loot.push(Loot::new(farthest, Item::Artifact(names::artifact(&mut rng))));
}
let lairs: Vec<Pos> = map
@@ -188,7 +215,51 @@ pub fn generate(seed: u64) -> Derelict {
if kind.unique && monsters.iter().any(|m| m.kind.name == kind.name) {
continue;
}
- monsters.push(Monster { kind, pos, hp: kind.hp, awake: false });
+ monsters.push(Monster::new(kind, pos));
+ }
+
+ // Weapons roll dice of their own, so arming the derelicts left every
+ // ship's rooms, loot and lairs where they were.
+ let mut rng = seed::rng(seed::derive(seed, "arms", 0));
+ let mut armed = false;
+ for plan in &plans {
+ let cells: Vec<Pos> = plan.cells.iter().copied().filter(|&p| free(&map, p)).collect();
+ if cells.is_empty() {
+ continue;
+ }
+ let kind = plan.kind;
+ let stocked = kind.arms >= 1.0;
+ let n =
+ if stocked { rng.random_range(1..=2) } else { u32::from(rng.random_bool(kind.arms)) };
+ for _ in 0..n {
+ let pos = *cells.choose(&mut rng).unwrap();
+ let weapon = arms::find(&mut rng, kind.source, stocked);
+ loot.push(Loot::new(pos, Item::Weapon(weapon)));
+ armed = true;
+ }
+ }
+ // And there's always something to fight with aboard.
+ if !armed && let Some(plan) = plans.iter().find(|p| p.cells.iter().any(|&c| free(&map, c))) {
+ let cells: Vec<Pos> = plan.cells.iter().copied().filter(|&p| free(&map, p)).collect();
+ let pos = *cells.choose(&mut rng).unwrap();
+ loot.push(Loot::new(pos, Item::Weapon(arms::find(&mut rng, plan.kind.source, false))));
+ }
+
+ // Armour too: armouries and berths mostly, warded pieces in chapels.
+ let mut rng = seed::rng(seed::derive(seed, "armour", 0));
+ for plan in &plans {
+ let cells: Vec<Pos> = plan.cells.iter().copied().filter(|&p| free(&map, p)).collect();
+ if cells.is_empty() {
+ continue;
+ }
+ let kind = plan.kind;
+ let stocked = kind.armour >= 1.0;
+ let n =
+ if stocked { rng.random_range(1..=2) } else { u32::from(rng.random_bool(kind.armour)) };
+ for _ in 0..n {
+ let pos = *cells.choose(&mut rng).unwrap();
+ loot.push(Loot::new(pos, Item::Armor(armour::find(&mut rng, kind.source, stocked))));
+ }
}
Derelict { name, description, map, start, monsters, loot }
@@ -436,13 +507,27 @@ mod tests {
assert!(map.tile(l.pos).walkable(), "seed {seed}: loot in a wall");
assert_ne!(l.pos, d.start);
}
- assert!(d.loot.iter().any(|l| matches!(l.item, Item::Artifact(_))));
+ assert!(d.loot.iter().any(|l| matches!(l.stack.item, Item::Artifact(_))));
+ assert!(
+ d.loot.iter().any(|l| matches!(l.stack.item, Item::Weapon(_))),
+ "seed {seed}: unarmed"
+ );
assert!(map.rooms.len() >= 4, "seed {seed}: only {} rooms", map.rooms.len());
assert!(d.monsters.iter().filter(|m| m.kind.unique).count() <= 1);
}
}
#[test]
+ fn armour_turns_up_in_most_wrecks() {
+ let armoured = (0..200)
+ .filter(|&seed| {
+ generate(seed).loot.iter().any(|l| matches!(l.stack.item, Item::Armor(_)))
+ })
+ .count();
+ assert!((80..195).contains(&armoured), "armour aboard {armoured} of 200 wrecks");
+ }
+
+ #[test]
fn same_seed_same_ship() {
let (a, b) = (generate(99), generate(99));
assert_eq!(a.name, b.name);
diff --git a/src/items.rs b/src/items.rs
new file mode 100644
index 0000000..6c38ecd
--- /dev/null
+++ b/src/items.rs
@@ -0,0 +1,728 @@
+//! Things you can carry: weapons, armour, food, medpatches, artifacts and
+//! salvage. What each weighs, how they stack, where they go on you, and the
+//! piles they lie in: your pack, the ship's lockers, the floor.
+//!
+//! Weights are in grams. Everything on you counts, worn, ready and packed,
+//! up to `Gear::capacity`; shipyards sell bigger packs. Your own cutting
+//! torch and rivet gun are always on you: they can't be dropped, stored or
+//! lost.
+//!
+//! Armour goes on your head, body, hands and feet, each piece guarding
+//! against its own kinds of blow (see `armour`). Food is ready for its
+//! first items: eating works, but nothing is hungry yet.
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+pub use crate::dungeon::armour::Armor;
+use crate::dungeon::arms::{self, Weapon};
+use crate::names::{self, Artifact};
+use crate::seed;
+
+/// What you can carry to begin with, in grams.
+pub const BASE_CAPACITY: u32 = 25_000;
+/// What the ship's lockers hold, in grams.
+pub const LOCKER_CAPACITY: u32 = 400_000;
+const MEDPATCH_MASS: u32 = 150;
+
+/// What a bigger pack is called, how much more it lets you carry and what a
+/// shipyard asks for it, in the order you can buy them.
+pub const PACKS: [(&str, u32, u32); 2] = [("frame pack", 10_000, 300), ("hauler rig", 10_000, 650)];
+
+/// The next bigger pack you could buy, if there is one.
+pub fn next_pack(capacity: u32) -> Option<(&'static str, u32, u32)> {
+ let mut have = BASE_CAPACITY;
+ for pack in PACKS {
+ if capacity < have + pack.1 {
+ return Some(pack);
+ }
+ have += pack.1;
+ }
+ None
+}
+
+/// The most you can carry with every pack there is.
+pub fn max_capacity() -> u32 {
+ BASE_CAPACITY + PACKS.iter().map(|p| p.1).sum::<u32>()
+}
+
+/// Grams as kilograms, to a decimal place: "2.1".
+pub fn kg(grams: u32) -> String {
+ format!("{:.1}", f64::from(grams) / 1000.0)
+}
+
+/// What sort of thing something is, in the order the inventory lists them.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub enum Category {
+ Weapon,
+ Armor,
+ Food,
+ Medical,
+ Artifact,
+ Salvage,
+}
+
+impl Category {
+ pub const ALL: [Category; 6] = [
+ Category::Weapon,
+ Category::Armor,
+ Category::Food,
+ Category::Medical,
+ Category::Artifact,
+ Category::Salvage,
+ ];
+
+ pub fn heading(self) -> &'static str {
+ match self {
+ Category::Weapon => "WEAPONS",
+ Category::Armor => "ARMOR",
+ Category::Food => "FOOD",
+ Category::Medical => "MEDICAL",
+ Category::Artifact => "ARTIFACTS",
+ Category::Salvage => "SALVAGE",
+ }
+ }
+}
+
+/// Where on you a piece of armour goes.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Slot {
+ Head,
+ Body,
+ Hands,
+ Feet,
+}
+
+impl Slot {
+ pub const ALL: [Slot; 4] = [Slot::Head, Slot::Body, Slot::Hands, Slot::Feet];
+
+ pub fn name(self) -> &'static str {
+ match self {
+ Slot::Head => "head",
+ Slot::Body => "body",
+ Slot::Hands => "hands",
+ Slot::Feet => "feet",
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Food {
+ /// "ration bar", without an article.
+ pub title: String,
+ pub description: String,
+ /// How much it fills you. Nothing gets hungry yet.
+ pub nourishment: u32,
+ pub mass: u32,
+}
+
+/// Something worth money and nothing else: sell it to a broker or a station.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Salvage {
+ /// "coil of superconductor", without an article.
+ pub title: String,
+ /// What it's worth, before what a buyer makes of it.
+ pub value: u32,
+ pub mass: u32,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Item {
+ Weapon(Weapon),
+ Armor(Armor),
+ Food(Food),
+ Medpatch,
+ Artifact(Artifact),
+ Salvage(Salvage),
+}
+
+impl Item {
+ pub fn category(&self) -> Category {
+ match self {
+ Item::Weapon(_) => Category::Weapon,
+ Item::Armor(_) => Category::Armor,
+ Item::Food(_) => Category::Food,
+ Item::Medpatch => Category::Medical,
+ Item::Artifact(_) => Category::Artifact,
+ Item::Salvage(_) => Category::Salvage,
+ }
+ }
+
+ /// What one weighs, in grams.
+ pub fn mass(&self) -> u32 {
+ match self {
+ Item::Weapon(w) => w.mass,
+ Item::Armor(a) => a.mass,
+ Item::Food(f) => f.mass,
+ Item::Medpatch => MEDPATCH_MASS,
+ Item::Artifact(a) => artifact_mass(a),
+ Item::Salvage(s) => s.mass,
+ }
+ }
+
+ /// What it's called in a list, without an article where it has one:
+ /// "serrated cleaver", "humming ochre lattice", "Khaseem's Mercy".
+ pub fn label(&self) -> String {
+ match self {
+ Item::Weapon(w) => w.title.clone(),
+ Item::Armor(a) => a.title.clone(),
+ Item::Food(f) => f.title.clone(),
+ Item::Medpatch => "medpatch".into(),
+ Item::Artifact(a) => {
+ let name = a.name();
+ name.strip_prefix("a ").or(name.strip_prefix("an ")).unwrap_or(name).to_string()
+ }
+ Item::Salvage(s) => s.title.clone(),
+ }
+ }
+
+ /// One of it, in a sentence: "a serrated cleaver", "your cutting
+ /// torch", "the Lacquered Orrery of Teshemond".
+ pub fn name(&self) -> String {
+ match self {
+ Item::Weapon(w) => w.name(),
+ Item::Armor(a) => a.name(),
+ Item::Artifact(a) => a.name().to_string(),
+ _ => names::a(&self.label()),
+ }
+ }
+
+ /// `n` of it, in a sentence: "a medpatch", "3 medpatches".
+ pub fn counted(&self, n: u32) -> String {
+ if n == 1 { self.name() } else { format!("{n} {}", plural(&self.label())) }
+ }
+
+ /// Whether more than one of it pile up as one.
+ pub fn stacks(&self) -> bool {
+ matches!(self, Item::Food(_) | Item::Medpatch | Item::Salvage(_))
+ }
+
+ /// Your own torch or rivet gun: never dropped, stored or lost.
+ pub fn is_kit(&self) -> bool {
+ matches!(self, Item::Weapon(w) if w.kit)
+ }
+}
+
+/// What an artifact weighs, fixed by its true name: from a carved token to
+/// a reliquary you need both arms for.
+fn artifact_mass(a: &Artifact) -> u32 {
+ let k = seed::derive(0, &a.true_name, 1) % 1000;
+ 300 + (k as u32 * k as u32) / 220
+}
+
+/// "medpatches", "coils of superconductor".
+pub fn plural(label: &str) -> String {
+ if let Some((head, tail)) = label.split_once(" of ") {
+ return format!("{} of {tail}", plural(head));
+ }
+ let consonant_y = label.ends_with('y')
+ && !label.ends_with("ay")
+ && !label.ends_with("ey")
+ && !label.ends_with("oy")
+ && !label.ends_with("uy");
+ if consonant_y {
+ format!("{}ies", &label[..label.len() - 1])
+ } else if ["s", "x", "z", "ch", "sh"].iter().any(|end| label.ends_with(end)) {
+ format!("{label}es")
+ } else {
+ format!("{label}s")
+ }
+}
+
+/// Some number of one thing.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Stack {
+ pub item: Item,
+ pub count: u32,
+}
+
+impl Stack {
+ pub fn one(item: Item) -> Stack {
+ Stack { item, count: 1 }
+ }
+
+ pub fn mass(&self) -> u32 {
+ self.item.mass().saturating_mul(self.count)
+ }
+
+ /// "3 medpatches", "a serrated cleaver".
+ pub fn name(&self) -> String {
+ self.item.counted(self.count)
+ }
+}
+
+/// Adds `stack` to a pile, onto a stack of the same thing if there is one.
+pub fn stow(pile: &mut Vec<Stack>, stack: Stack) {
+ if stack.count == 0 {
+ return;
+ }
+ if stack.item.stacks()
+ && let Some(same) = pile.iter_mut().find(|s| s.item == stack.item)
+ {
+ same.count += stack.count;
+ return;
+ }
+ pile.push(stack);
+}
+
+/// Takes up to `count` of the stack at `i` out of a pile.
+pub fn take(pile: &mut Vec<Stack>, i: usize, count: u32) -> Stack {
+ let count = count.min(pile[i].count);
+ if count == pile[i].count {
+ pile.remove(i)
+ } else {
+ pile[i].count -= count;
+ Stack { item: pile[i].item.clone(), count }
+ }
+}
+
+pub fn pile_mass(pile: &[Stack]) -> u32 {
+ pile.iter().map(Stack::mass).sum()
+}
+
+/// How many of `item` fit in `room` grams.
+pub fn how_many_fit(item: &Item, room: u32) -> u32 {
+ room.checked_div(item.mass()).unwrap_or(u32::MAX)
+}
+
+/// Where something is on you.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Spot {
+ Worn(Slot),
+ /// The weapon you hit things with.
+ Melee,
+ /// The gun you fire.
+ Gun,
+ /// A stack in your pack, by index.
+ Pack(usize),
+}
+
+/// Everything you carry: what you wear, your two ready weapons, and your
+/// pack.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Gear {
+ pub melee: Weapon,
+ pub gun: Weapon,
+ #[serde(default)]
+ pub worn: BTreeMap<Slot, Armor>,
+ #[serde(default)]
+ pub pack: Vec<Stack>,
+ /// How much you can carry, all told, in grams.
+ pub capacity: u32,
+}
+
+impl Default for Gear {
+ fn default() -> Gear {
+ Gear {
+ melee: arms::cutting_torch(),
+ gun: arms::rivet_gun(),
+ worn: BTreeMap::new(),
+ pack: Vec::new(),
+ capacity: BASE_CAPACITY,
+ }
+ }
+}
+
+impl Gear {
+ /// What everything on you weighs, in grams.
+ pub fn carried(&self) -> u32 {
+ let worn: u32 = self.worn.values().map(|a| a.mass).sum();
+ self.melee.mass + self.gun.mass + worn + pile_mass(&self.pack)
+ }
+
+ /// How many more grams you could carry.
+ pub fn room(&self) -> u32 {
+ self.capacity.saturating_sub(self.carried())
+ }
+
+ /// How many of `item` you could take on.
+ pub fn fits(&self, item: &Item) -> u32 {
+ how_many_fit(item, self.room())
+ }
+
+ /// What's at `spot`, as a stack, for showing.
+ pub fn get(&self, spot: Spot) -> Option<Stack> {
+ match spot {
+ Spot::Worn(slot) => self.worn.get(&slot).map(|a| Stack::one(Item::Armor(a.clone()))),
+ Spot::Melee => Some(Stack::one(Item::Weapon(self.melee.clone()))),
+ Spot::Gun => Some(Stack::one(Item::Weapon(self.gun.clone()))),
+ Spot::Pack(i) => self.pack.get(i).cloned(),
+ }
+ }
+
+ /// Puts `stack` in your pack, whatever it weighs.
+ pub fn add(&mut self, stack: Stack) {
+ stow(&mut self.pack, stack);
+ }
+
+ /// Every weapon on you, ready or packed.
+ #[cfg(test)]
+ pub fn weapons(&self) -> impl Iterator<Item = &Weapon> {
+ let packed = self.pack.iter().filter_map(|s| match &s.item {
+ Item::Weapon(w) => Some(w),
+ _ => None,
+ });
+ [&self.melee, &self.gun].into_iter().chain(packed)
+ }
+
+ pub fn medpatches(&self) -> u32 {
+ self.pack.iter().filter(|s| s.item == Item::Medpatch).map(|s| s.count).sum()
+ }
+
+ /// Takes a medpatch out of your pack to use, if you have one.
+ pub fn take_medpatch(&mut self) -> bool {
+ let Some(i) = self.pack.iter().position(|s| s.item == Item::Medpatch) else {
+ return false;
+ };
+ take(&mut self.pack, i, 1);
+ true
+ }
+
+ /// What everything you wear takes off a blow of `dmg`, as it is now.
+ #[cfg(test)]
+ pub fn guard(&self, dmg: arms::Dmg) -> i32 {
+ self.worn.values().map(|a| a.guard(dmg)).sum()
+ }
+
+ /// Every piece of armour on you, worn or packed.
+ pub fn armour_mut(&mut self) -> impl Iterator<Item = &mut Armor> {
+ let packed = self.pack.iter_mut().filter_map(|s| match &mut s.item {
+ Item::Armor(a) => Some(a),
+ _ => None,
+ });
+ self.worn.values_mut().chain(packed)
+ }
+
+ /// Readies the weapon at `i` in your pack: a gun to fire, anything
+ /// else to hit things with. What was ready goes in its place.
+ pub fn ready(&mut self, i: usize) -> Result<String, String> {
+ let Some(Stack { item: Item::Weapon(w), .. }) = self.pack.get(i) else {
+ return Err("That's not something to fight with.".into());
+ };
+ let gun = w.is_gun();
+ let Item::Weapon(w) = take(&mut self.pack, i, 1).item else { unreachable!() };
+ let yours = w.yours();
+ let hand = if gun { &mut self.gun } else { &mut self.melee };
+ let old = std::mem::replace(hand, w);
+ self.add(Stack::one(Item::Weapon(old)));
+ Ok(format!("You ready {yours}."))
+ }
+
+ /// Puts on the armour at `i` in your pack, taking off whatever was there.
+ pub fn wear(&mut self, i: usize) -> Result<String, String> {
+ let Some(Stack { item: Item::Armor(_), .. }) = self.pack.get(i) else {
+ return Err("You can't wear that.".into());
+ };
+ let Item::Armor(a) = take(&mut self.pack, i, 1).item else { unreachable!() };
+ let what = format!("You put on {}.", a.name());
+ if let Some(old) = self.worn.insert(a.slot, a) {
+ self.add(Stack::one(Item::Armor(old)));
+ }
+ Ok(what)
+ }
+
+ /// Takes off what you wear on `slot`, into your pack.
+ pub fn take_off(&mut self, slot: Slot) -> Result<String, String> {
+ let Some(a) = self.worn.remove(&slot) else {
+ return Err(format!("You've nothing on your {}.", slot.name()));
+ };
+ let what = format!("You take off {}.", a.yours());
+ self.add(Stack::one(Item::Armor(a)));
+ Ok(what)
+ }
+
+ /// Eats one of the food at `i` in your pack.
+ pub fn eat(&mut self, i: usize) -> Result<String, String> {
+ let Some(Stack { item: Item::Food(f), .. }) = self.pack.get(i) else {
+ return Err("You can't eat that.".into());
+ };
+ let what = format!("You eat {}.", names::a(&f.title));
+ take(&mut self.pack, i, 1);
+ Ok(what)
+ }
+
+ /// Where in your pack the weapon is that would take the place of the
+ /// gun (or the one to hit with) if you put that down: your own, if it's
+ /// there, or else the first.
+ pub fn spare(&self, gun: bool) -> Option<usize> {
+ let fits = |s: &Stack, own: bool| matches!(&s.item, Item::Weapon(w) if w.is_gun() == gun && (w.kit || !own));
+ let own = self.pack.iter().position(|s| fits(s, true));
+ own.or_else(|| self.pack.iter().position(|s| fits(s, false)))
+ }
+
+ /// Takes up to `count` of what's at `spot` off you, to drop or store.
+ /// Put down a weapon you had ready and another from your pack comes out
+ /// in its place; with nothing else to fight with, you keep it.
+ pub fn remove(&mut self, spot: Spot, count: u32) -> Result<Stack, String> {
+ match spot {
+ Spot::Worn(slot) => match self.worn.remove(&slot) {
+ Some(a) => Ok(Stack::one(Item::Armor(a))),
+ None => Err(format!("You've nothing on your {}.", slot.name())),
+ },
+ Spot::Melee | Spot::Gun => {
+ let gun = spot == Spot::Gun;
+ let Some(i) = self.spare(gun) else {
+ let what = if gun { "shoot with" } else { "hit things with" };
+ return Err(format!(
+ "You'd have nothing to {what}. Ready something else first."
+ ));
+ };
+ let Item::Weapon(spare) = take(&mut self.pack, i, 1).item else { unreachable!() };
+ let hand = if gun { &mut self.gun } else { &mut self.melee };
+ Ok(Stack::one(Item::Weapon(std::mem::replace(hand, spare))))
+ }
+ Spot::Pack(i) => match self.pack.get(i) {
+ None => Err("There's nothing there.".into()),
+ Some(_) => Ok(take(&mut self.pack, i, count.max(1))),
+ },
+ }
+ }
+
+ pub fn reload_all(&mut self) {
+ self.melee.reload();
+ self.gun.reload();
+ for s in &mut self.pack {
+ if let Item::Weapon(w) = &mut s.item {
+ w.reload();
+ }
+ }
+ }
+
+ /// What's left when everything you carried stays aboard a wreck: your
+ /// own torch and rivet gun, if you had them on you. A hand left with
+ /// neither gets yours from `elsewhere` (the lockers), or failing that a
+ /// spare from the ship's stores: you never go about with nothing to
+ /// fight with.
+ pub fn stripped(&self, mut elsewhere: impl FnMut(bool) -> Option<Weapon>) -> Gear {
+ let packed = self.pack.iter().filter_map(|s| match &s.item {
+ Item::Weapon(w) => Some(w.clone()),
+ _ => None,
+ });
+ let mut own: Vec<Weapon> = [self.melee.clone(), self.gun.clone()]
+ .into_iter()
+ .chain(packed)
+ .filter(|w| w.kit)
+ .collect();
+ let mut hand = |gun: bool| match own.iter().position(|w| w.is_gun() == gun) {
+ Some(i) => own.remove(i),
+ None => elsewhere(gun)
+ .unwrap_or_else(|| if gun { arms::rivet_gun() } else { arms::cutting_torch() }),
+ };
+ let (melee, gun) = (hand(false), hand(true));
+ let mut gear = Gear { melee, gun, capacity: self.capacity, ..Gear::default() };
+ for w in own {
+ gear.add(Stack::one(Item::Weapon(w)));
+ }
+ gear
+ }
+
+ /// Puts right whatever a save file got wrong: the right sort of weapon
+ /// ready in each hand, sensible numbers, nothing empty, no more packs
+ /// than there are.
+ pub fn mend(&mut self) {
+ self.capacity = self.capacity.clamp(BASE_CAPACITY, max_capacity());
+ self.pack.retain(|s| s.count > 0);
+ if self.melee.is_gun() && !self.gun.is_gun() {
+ std::mem::swap(&mut self.melee, &mut self.gun);
+ }
+ for gun in [false, true] {
+ let hand = if gun { &self.gun } else { &self.melee };
+ if hand.is_gun() == gun {
+ continue;
+ }
+ let right = match self.spare(gun) {
+ Some(i) => match take(&mut self.pack, i, 1).item {
+ Item::Weapon(w) => w,
+ _ => unreachable!(),
+ },
+ None if gun => arms::rivet_gun(),
+ None => arms::cutting_torch(),
+ };
+ let hand = if gun { &mut self.gun } else { &mut self.melee };
+ let wrong = std::mem::replace(hand, right);
+ self.add(Stack::one(Item::Weapon(wrong)));
+ }
+ for s in &mut self.pack {
+ if !s.item.stacks() {
+ s.count = 1;
+ }
+ if let Item::Weapon(w) = &mut s.item {
+ w.mend();
+ }
+ }
+ self.melee.mend();
+ self.gun.mend();
+ for (slot, a) in &mut self.worn {
+ a.slot = *slot;
+ }
+ for a in self.armour_mut() {
+ a.mend();
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::dungeon::arms::{Dmg, Source};
+
+ fn found(gun: bool) -> Weapon {
+ let mut rng = seed::rng(3);
+ (0..)
+ .map(|_| arms::find(&mut rng, Source::Armoury, false))
+ .find(|w| w.is_gun() == gun)
+ .unwrap()
+ }
+
+ fn helmet() -> Armor {
+ Armor {
+ title: "patched vac-helmet".into(),
+ description: "a patched vac-helmet".into(),
+ slot: Slot::Head,
+ guards: [(Dmg::Pierce, 1), (Dmg::Blunt, 1)].into_iter().collect(),
+ stanches: false,
+ durability: 20,
+ max_durability: 20,
+ condition: arms::Condition::Worn,
+ named: false,
+ pair: false,
+ mass: 1800,
+ }
+ }
+
+ #[test]
+ fn like_things_stack_and_unlike_ones_dont() {
+ let mut pile = Vec::new();
+ stow(&mut pile, Stack::one(Item::Medpatch));
+ stow(&mut pile, Stack { item: Item::Medpatch, count: 2 });
+ stow(&mut pile, Stack::one(Item::Weapon(found(false))));
+ stow(&mut pile, Stack::one(Item::Weapon(found(false))));
+ assert_eq!(pile.len(), 3);
+ assert_eq!(pile[0].count, 3);
+ assert_eq!(take(&mut pile, 0, 2).count, 2);
+ assert_eq!(pile[0].count, 1);
+ assert_eq!(take(&mut pile, 0, 5).count, 1, "no more than there is");
+ assert_eq!(pile.len(), 2);
+ }
+
+ #[test]
+ fn names_count_and_pluralise() {
+ assert_eq!(Item::Medpatch.counted(1), "a medpatch");
+ assert_eq!(Item::Medpatch.counted(3), "3 medpatches");
+ assert_eq!(plural("coil of superconductor"), "coils of superconductor");
+ assert_eq!(plural("box of votive candles"), "boxes of votive candles");
+ assert_eq!(plural("battery"), "batteries");
+ }
+
+ #[test]
+ fn readying_swaps_with_what_was_ready() {
+ let mut gear = Gear::default();
+ let blade = found(false);
+ let carried = gear.carried();
+ gear.add(Stack::one(Item::Weapon(blade.clone())));
+ assert_eq!(gear.carried(), carried + blade.mass);
+ gear.ready(0).unwrap();
+ assert_eq!(gear.melee, blade);
+ assert!(matches!(&gear.pack[0].item, Item::Weapon(w) if w.kit && !w.is_gun()));
+ // Your own torch can go too, once it's not in your hand.
+ let torch = gear.remove(Spot::Pack(0), 1).unwrap();
+ assert!(gear.remove(Spot::Melee, 1).is_err(), "not with nothing else to hit with");
+ assert!(gear.remove(Spot::Gun, 1).is_err(), "nor to shoot with");
+ // Put the blade down and what's in your pack comes out in its place.
+ gear.add(torch);
+ let dropped = gear.remove(Spot::Melee, 1).unwrap();
+ assert_eq!(dropped.item, Item::Weapon(blade));
+ assert!(gear.melee.kit && gear.pack.is_empty());
+ }
+
+ #[test]
+ fn stripped_you_keep_your_own_and_never_go_unarmed() {
+ let mut gear = Gear::default();
+ gear.add(Stack::one(Item::Weapon(found(false))));
+ gear.add(Stack { item: Item::Medpatch, count: 3 });
+ let kept = gear.stripped(|_| None);
+ assert_eq!(kept, Gear::default(), "your own, and nothing else");
+ // With your torch left in the lockers, it comes out of them.
+ gear.ready(0).unwrap();
+ let torch = gear.remove(Spot::Pack(gear.spare(false).unwrap()), 1).unwrap();
+ let mut lockers = vec![torch];
+ let kept = gear.stripped(|gun| match (gun, lockers.pop()) {
+ (false, Some(Stack { item: Item::Weapon(w), .. })) => Some(w),
+ _ => None,
+ });
+ assert!(kept.melee.kit && kept.gun.kit && kept.pack.is_empty());
+ assert!(lockers.is_empty());
+ }
+
+ #[test]
+ fn armour_goes_on_and_comes_off() {
+ let mut gear = Gear::default();
+ gear.add(Stack::one(Item::Armor(helmet())));
+ assert_eq!(gear.wear(0).unwrap(), "You put on a patched vac-helmet.");
+ assert_eq!((gear.guard(Dmg::Pierce), gear.guard(Dmg::Cut)), (1, 0));
+ assert!(gear.pack.is_empty());
+ let mut better = helmet();
+ better.guards.insert(Dmg::Pierce, 2);
+ gear.add(Stack::one(Item::Armor(better)));
+ gear.wear(0).unwrap();
+ assert_eq!(gear.guard(Dmg::Pierce), 2, "the better helmet went on");
+ assert_eq!(gear.pack.len(), 1, "and the old one came off");
+ assert_eq!(gear.take_off(Slot::Head).unwrap(), "You take off your patched vac-helmet.");
+ assert_eq!((gear.guard(Dmg::Pierce), gear.pack.len()), (0, 2));
+ assert!(gear.take_off(Slot::Feet).is_err());
+ }
+
+ #[test]
+ fn food_is_eaten_one_at_a_time() {
+ let mut gear = Gear::default();
+ let bar = Food {
+ title: "ration bar".into(),
+ description: "dense and faintly sweet".into(),
+ nourishment: 1,
+ mass: 200,
+ };
+ gear.add(Stack { item: Item::Food(bar), count: 2 });
+ assert_eq!(gear.eat(0).unwrap(), "You eat a ration bar.");
+ assert_eq!(gear.pack[0].count, 1);
+ gear.eat(0).unwrap();
+ assert!(gear.pack.is_empty());
+ assert!(gear.eat(0).is_err());
+ }
+
+ #[test]
+ fn you_carry_what_you_have_room_for() {
+ let gear = Gear::default();
+ assert!(gear.carried() < gear.capacity);
+ assert_eq!(gear.fits(&Item::Medpatch), gear.room() / MEDPATCH_MASS);
+ assert_eq!(next_pack(BASE_CAPACITY).unwrap().0, "frame pack");
+ assert_eq!(next_pack(BASE_CAPACITY + PACKS[0].1).unwrap().0, "hauler rig");
+ assert_eq!(next_pack(max_capacity()), None);
+ }
+
+ #[test]
+ fn mending_puts_your_own_kit_back() {
+ let mut gear = Gear::default();
+ let (blade, gun) = (found(false), found(true));
+ gear.melee = gun.clone();
+ gear.gun = blade.clone();
+ gear.pack.clear();
+ gear.capacity = 0;
+ gear.mend();
+ assert!(!gear.melee.is_gun() && gear.gun.is_gun());
+ assert_eq!(gear.weapons().count(), 2, "swapped, with nothing made up");
+ assert!(gear.weapons().any(|w| *w == blade) && gear.weapons().any(|w| *w == gun));
+ assert_eq!(gear.capacity, BASE_CAPACITY);
+ }
+
+ #[test]
+ fn artifacts_weigh_something_sensible() {
+ let mut rng = seed::rng(5);
+ for _ in 0..200 {
+ let mass = Item::Artifact(names::artifact(&mut rng)).mass();
+ assert!((300..=5000).contains(&mass), "{mass} g");
+ }
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index e2b74d3..e46a1e6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,22 +1,27 @@
//! Orrery: an ASCII sci-fi-fantasy game.
//!
-//! `orrery` plays; `orrery names` and `orrery ship` print what the
-//! generators make, without the TUI.
+//! `orrery` plays; `orrery names`, `ship`, `galaxy`, `system` and `town` print
+//! what the generators make, without the TUI.
mod app;
mod cli;
mod dungeon;
+mod items;
mod names;
+mod save;
mod scenes;
mod seed;
+mod space;
mod term;
+mod town;
+mod trade;
use std::process::ExitCode;
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match cli::parse(&args) {
- Ok(cli::Command::Play { seed }) => match app::run(seed.unwrap_or_else(seed::from_clock)) {
+ Ok(cli::Command::Play { seed }) => match app::run(seed) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("orrery: {e}");
@@ -31,6 +36,26 @@ fn main() -> ExitCode {
cli::print_ship(seed, which);
ExitCode::SUCCESS
}
+ Ok(cli::Command::Weapons { seed, count }) => {
+ cli::print_weapons(seed, count);
+ ExitCode::SUCCESS
+ }
+ Ok(cli::Command::Armour { seed, count }) => {
+ cli::print_armour(seed, count);
+ ExitCode::SUCCESS
+ }
+ Ok(cli::Command::Galaxy { seed }) => {
+ cli::print_galaxy(seed);
+ ExitCode::SUCCESS
+ }
+ Ok(cli::Command::System { seed, which }) => {
+ cli::print_system(seed, which);
+ ExitCode::SUCCESS
+ }
+ Ok(cli::Command::Town { seed, which, inside }) => {
+ cli::print_town(seed, which, inside);
+ ExitCode::SUCCESS
+ }
Ok(cli::Command::Help) => {
print!("{}", cli::USAGE);
ExitCode::SUCCESS
diff --git a/src/names/artifact.rs b/src/names/artifact.rs
index af24b3d..336eef4 100644
--- a/src/names/artifact.rs
+++ b/src/names/artifact.rs
@@ -2,22 +2,31 @@
//! called once a scholar has told you what it is.
use rand::RngExt;
+use serde::{Deserialize, Serialize};
use super::{Culture, pick};
use crate::seed::Rng;
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Artifact {
/// What you see when you find it: "a humming ochre lattice".
pub unidentified: String,
/// Its true name: "the Lacquered Orrery of Teshemond".
pub true_name: String,
+ /// Whether a scholar has told you its true name.
+ pub identified: bool,
+}
+
+impl Artifact {
+ /// What you call it: its true name once you know it.
+ pub fn name(&self) -> &str {
+ if self.identified { &self.true_name } else { &self.unidentified }
+ }
}
pub fn artifact(rng: &mut Rng) -> Artifact {
let look = format!("{} {} {}", pick(QUALITIES, rng), pick(COLOURS, rng), pick(SHAPES, rng));
- let article = if look.starts_with(['a', 'e', 'i', 'o', 'u']) { "an" } else { "a" };
- let unidentified = format!("{article} {look}");
+ let unidentified = super::a(&look);
let object = pick(OBJECTS, rng);
let culture = Culture::ALL[rng.random_range(..Culture::ALL.len())];
@@ -28,7 +37,7 @@ pub fn artifact(rng: &mut Rng) -> Artifact {
3 => format!("the {} {object}", pick(EPITHETS, rng)),
_ => format!("the {object} {}", pick(CLAUSES, rng)),
};
- Artifact { unidentified, true_name }
+ Artifact { unidentified, true_name, identified: false }
}
const QUALITIES: &[&str] = &[
diff --git a/src/names/mod.rs b/src/names/mod.rs
index 719455f..9b5d9a6 100644
--- a/src/names/mod.rs
+++ b/src/names/mod.rs
@@ -21,10 +21,12 @@ use std::fmt::Write as _;
use rand::RngExt;
use rand::seq::IndexedRandom;
+use serde::{Deserialize, Serialize};
use crate::seed::Rng;
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
pub enum Culture {
Qaari,
Cant,
@@ -198,7 +200,7 @@ impl Culture {
self.expand(pick(self.phonology().places, rng), rng)
}
- fn expand(self, template: &str, rng: &mut Rng) -> String {
+ pub(crate) fn expand(self, template: &str, rng: &mut Rng) -> String {
let mut out = String::new();
let mut rest = template;
while let Some(open) = rest.find('{') {
@@ -217,6 +219,13 @@ impl Culture {
}
}
+/// The phrase with "a" or "an" in front, by its first letter: "an ice
+/// world", "a desert world".
+pub fn a(phrase: &str) -> String {
+ let article = if phrase.starts_with(['a', 'e', 'i', 'o', 'u']) { "an" } else { "a" };
+ format!("{article} {phrase}")
+}
+
pub(crate) fn pick(list: &'static [&'static str], rng: &mut Rng) -> &'static str {
list.choose(rng).expect("name lists are never empty")
}
diff --git a/src/save.rs b/src/save.rs
new file mode 100644
index 0000000..23c7b62
--- /dev/null
+++ b/src/save.rs
@@ -0,0 +1,790 @@
+//! Characters, and saving them.
+//!
+//! A save is a universe seed plus everything you've changed in it: who you
+//! are, what's in your hold, where your ship is, which wrecks you've
+//! stripped, what you've learned about what lives aboard them. The universe
+//! itself isn't saved; it's regenerated from the seed, which is one more
+//! reason seeds must stay stable (see `seed`).
+//!
+//! Each character is one JSON file in the saves folder,
+//! `$XDG_DATA_HOME/orrery/saves` or `~/.local/share/orrery/saves`. The game
+//! writes it whenever the scene changes and when you quit.
+
+use std::collections::BTreeMap;
+use std::fs;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use serde::{Deserialize, Serialize};
+
+use crate::app::{Ctx, Hold};
+use crate::dungeon::Pos;
+use crate::dungeon::arms::{self, Dmg, Weapon};
+use crate::dungeon::crawl::{Crawl, MONSTERS, SavedCrawl};
+use crate::items::{self, Armor, Gear, Item, Stack};
+use crate::names::{Artifact, Culture};
+use crate::scenes::inventory::Sort;
+use crate::space::locale::ORES;
+use crate::trade::{MAX_FUEL, MEDPATCH_LIMIT, Rack};
+
+/// The save format. Bump it when a change means older Orreries can't read
+/// what this one writes; this one reads any save at or below it. 2: the
+/// inventory, which an older Orrery would read as having nothing on you.
+pub const VERSION: u32 = 2;
+
+/// Who you are.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Character {
+ /// "Khaseem ul-Tirun".
+ pub name: String,
+ /// Where you're from. For now it only flavours the names you're offered.
+ pub origin: Culture,
+ /// Your ship, without "the": "Ithane".
+ pub ship: String,
+}
+
+impl Character {
+ /// "the Ithane".
+ pub fn ship_name(&self) -> String {
+ format!("the {}", self.ship)
+ }
+
+ /// "a Qaari salvager".
+ pub fn calling(&self) -> String {
+ format!("{} salvager", crate::names::a(self.origin.adjective()))
+ }
+}
+
+/// What you've done, for the character sheet.
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Record {
+ /// Wrecks you've gone aboard.
+ pub boarded: u32,
+ /// Of those, how many you got back from with what you found.
+ pub escaped: u32,
+ /// Times you were brought down and dragged back by your crew.
+ pub downed: u32,
+ /// Times you left by the emergency hatch.
+ pub abandoned: u32,
+ /// What you've killed, by kind.
+ pub slain: BTreeMap<String, u32>,
+}
+
+impl Record {
+ pub fn slain_total(&self) -> u32 {
+ self.slain.values().sum()
+ }
+}
+
+/// A save file's contents. Everything is in plain owned types, so a file
+/// says what it means without the code that wrote it.
+#[derive(Serialize, Deserialize)]
+struct Save {
+ version: u32,
+ /// When it was written, in seconds since 1970.
+ saved: u64,
+ character: Character,
+ seed: u64,
+ day: f64,
+ /// The day you set out for where you are, so the wrecks you came for
+ /// are still there.
+ #[serde(default)]
+ departed: Option<f64>,
+ /// Which system you're in. Saves from before there were others are in
+ /// the system their universe started in.
+ #[serde(default)]
+ star: Option<usize>,
+ /// The systems you've been to.
+ #[serde(default)]
+ visited: Vec<usize>,
+ location: usize,
+ /// The name of where you are, for the list of saves.
+ #[serde(default)]
+ place: String,
+ /// Whether your ship is set down there, rather than in flight.
+ #[serde(default)]
+ landed: bool,
+ #[serde(default)]
+ boarded: u64,
+ #[serde(default)]
+ lenses: u32,
+ #[serde(default)]
+ stripped: Vec<u64>,
+ hold: SavedHold,
+ #[serde(default)]
+ bestiary: Vec<(String, Dmg)>,
+ #[serde(default)]
+ record: Record,
+ /// The wreck you were aboard, if you were in one.
+ #[serde(default)]
+ crawl: Option<SavedCrawl>,
+ /// Armourers' racks as you left them this fortnight.
+ #[serde(default)]
+ racks: Vec<Rack>,
+ /// How you had your inventory sorted.
+ #[serde(default, deserialize_with = "lenient")]
+ inventory_sort: Sort,
+}
+
+/// A sort from a save, or the usual one if it names a sort there's no
+/// longer.
+fn lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Sort, D::Error> {
+ let value = serde_json::Value::deserialize(d)?;
+ Ok(Sort::deserialize(value).unwrap_or_default())
+}
+
+#[derive(Serialize, Deserialize)]
+struct SavedHold {
+ #[serde(default)]
+ scrip: u32,
+ /// Tons of each ore, by name.
+ #[serde(default)]
+ ore: BTreeMap<String, u32>,
+ ore_capacity: u32,
+ /// What's on you. Saves from before the inventory have `artifacts`,
+ /// `medpatches` and `arms` instead.
+ #[serde(default)]
+ gear: Option<Gear>,
+ #[serde(default)]
+ lockers: Vec<Stack>,
+ #[serde(default)]
+ deck: Vec<(Pos, Stack)>,
+ #[serde(default = "full_tank")]
+ fuel: u32,
+ #[serde(default = "full_tank")]
+ fuel_capacity: u32,
+ #[serde(default)]
+ debt: u32,
+ // From before the inventory: read, never written.
+ #[serde(default, skip_serializing)]
+ artifacts: Vec<Artifact>,
+ #[serde(default, skip_serializing)]
+ medpatches: u32,
+ #[serde(default, skip_serializing)]
+ arms: Option<OldArms>,
+}
+
+/// How weapons were saved before the inventory: all of them in one list,
+/// with which were ready.
+#[derive(Deserialize)]
+struct OldArms {
+ weapons: Vec<Weapon>,
+ melee: usize,
+ ranged: usize,
+}
+
+/// What a save from before the inventory had on you and aboard: the
+/// weapons and medpatches you took into every wreck go on you, and the
+/// artifacts from the shelf go in the lockers.
+fn from_before(h: &mut SavedHold) -> (Gear, Vec<Stack>) {
+ let mut gear = Gear::default();
+ if let Some(OldArms { weapons, melee, ranged }) = h.arms.take() {
+ let (mut blade, mut gun) = (None, None);
+ for (i, w) in weapons.into_iter().enumerate() {
+ if i == melee && !w.is_gun() && blade.is_none() {
+ blade = Some(w);
+ } else if i == ranged && w.is_gun() && gun.is_none() {
+ gun = Some(w);
+ } else {
+ gear.add(Stack::one(Item::Weapon(w)));
+ }
+ }
+ gear.melee = blade.unwrap_or_else(arms::cutting_torch);
+ gear.gun = gun.unwrap_or_else(arms::rivet_gun);
+ }
+ gear.add(Stack { item: Item::Medpatch, count: h.medpatches.min(MEDPATCH_LIMIT) });
+ let lockers = h.artifacts.drain(..).map(|a| Stack::one(Item::Artifact(a))).collect();
+ (gear, lockers)
+}
+
+/// Puts right a pile a save file got wrong: nothing empty, no copies of
+/// what can't be copied, none of your own kit (which is always on you),
+/// every weapon's numbers sensible.
+fn mend_pile(pile: &mut Vec<Stack>) {
+ pile.retain(|s| s.count > 0);
+ pile.iter_mut().for_each(mend_stack);
+}
+
+fn mend_stack(s: &mut Stack) {
+ if !s.item.stacks() {
+ s.count = 1;
+ }
+ match &mut s.item {
+ Item::Weapon(w) => w.mend(),
+ Item::Armor(a) => a.mend(),
+ _ => {}
+ }
+}
+
+/// If you're carrying more than you can, the heaviest things in your pack
+/// go to the lockers until you aren't.
+fn unburden(gear: &mut Gear, lockers: &mut Vec<Stack>) {
+ while gear.carried() > gear.capacity {
+ let heaviest = (0..gear.pack.len()).max_by_key(|&i| gear.pack[i].mass());
+ let Some(i) = heaviest else { break };
+ let count = gear.pack[i].count;
+ items::stow(lockers, items::take(&mut gear.pack, i, count));
+ }
+}
+
+fn full_tank() -> u32 {
+ Hold::BASE_FUEL
+}
+
+/// What the list of saves shows about one, without loading it.
+pub struct Summary {
+ pub character: Character,
+ pub day: f64,
+ pub place: String,
+ pub scrip: u32,
+ pub record: Record,
+ /// Seconds since 1970.
+ pub saved: u64,
+}
+
+/// A file in the saves folder: what's in it, or why it can't be read.
+pub struct Slot {
+ pub path: PathBuf,
+ pub summary: Result<Summary, String>,
+}
+
+/// Where saves go: `$XDG_DATA_HOME/orrery/saves`, or
+/// `~/.local/share/orrery/saves`.
+pub fn default_dir() -> Option<PathBuf> {
+ let data = std::env::var_os("XDG_DATA_HOME")
+ .map(PathBuf::from)
+ .filter(|p| p.is_absolute())
+ .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
+ Some(data.join("orrery").join("saves"))
+}
+
+/// A new file for `character` in universe `seed`: their name, made safe for
+/// a file name, and the universe, so two characters of the same name don't
+/// share one.
+pub fn new_slot(dir: &Path, character: &Character, seed: u64) -> PathBuf {
+ let mut slug = String::new();
+ for c in character.name.chars().flat_map(char::to_lowercase) {
+ if c.is_ascii_alphanumeric() {
+ slug.push(c);
+ } else if !slug.is_empty() && !slug.ends_with('-') {
+ slug.push('-');
+ }
+ }
+ let slug: String = slug.trim_end_matches('-').chars().take(24).collect();
+ let slug = if slug.is_empty() { "character".to_string() } else { slug };
+ dir.join(format!("{slug}-{seed:016x}.json"))
+}
+
+/// Every save in `dir`, most recently played first; the ones that can't be
+/// read come last, with why.
+pub fn list(dir: &Path) -> Vec<Slot> {
+ let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
+ let mut slots: Vec<Slot> = entries
+ .filter_map(Result::ok)
+ .map(|e| e.path())
+ .filter(|p| p.extension().is_some_and(|x| x == "json"))
+ .map(|path| {
+ let summary = read(&path).map(|s| Summary {
+ day: s.day,
+ place: s.place,
+ scrip: s.hold.scrip,
+ record: s.record,
+ saved: s.saved,
+ character: s.character,
+ });
+ Slot { path, summary }
+ })
+ .collect();
+ slots.sort_by_key(|s| (s.summary.as_ref().map_or(1, |_| 0), std::cmp::Reverse(saved(s))));
+ slots
+}
+
+fn saved(slot: &Slot) -> u64 {
+ slot.summary.as_ref().map_or(0, |s| s.saved)
+}
+
+/// Writes the game in `ctx` to its slot. Writes a new file and moves it
+/// over the old, so a crash halfway through leaves the old save whole.
+pub fn write(ctx: &Ctx) -> io::Result<()> {
+ let Some(path) = &ctx.slot else { return Ok(()) };
+ write_save(path, &capture(ctx))
+}
+
+fn write_save(path: &Path, save: &Save) -> io::Result<()> {
+ if let Some(dir) = path.parent() {
+ fs::create_dir_all(dir)?;
+ }
+ let json = serde_json::to_string_pretty(save).map_err(io::Error::other)?;
+ let fresh = path.with_extension("json.new");
+ fs::write(&fresh, json + "\n")?;
+ fs::rename(&fresh, path)
+}
+
+/// Loads the save at `path` into `ctx`, which becomes that character's game
+/// from then on. If the save can't be read, says why and leaves `ctx` as it
+/// was.
+pub fn load(path: &Path, ctx: &mut Ctx) -> Result<(), String> {
+ let save = read(path)?;
+ restore(save, ctx);
+ ctx.slot = Some(path.to_path_buf());
+ Ok(())
+}
+
+pub fn delete(path: &Path) -> io::Result<()> {
+ fs::remove_file(path)
+}
+
+fn read(path: &Path) -> Result<Save, String> {
+ let text = fs::read_to_string(path).map_err(|e| e.to_string())?;
+ let save: Save = serde_json::from_str(&text).map_err(|e| e.to_string())?;
+ if save.version > VERSION {
+ return Err(format!("saved by a newer Orrery (save format {})", save.version));
+ }
+ Ok(save)
+}
+
+/// Seconds since 1970.
+pub fn now() -> u64 {
+ SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
+}
+
+fn capture(ctx: &Ctx) -> Save {
+ let h = &ctx.hold;
+ let mut stripped: Vec<u64> = ctx.stripped.iter().copied().collect();
+ stripped.sort_unstable();
+ Save {
+ version: VERSION,
+ saved: now(),
+ character: ctx.character.clone(),
+ seed: ctx.seed,
+ day: ctx.day,
+ departed: ctx.departed,
+ star: Some(ctx.star),
+ visited: ctx.visited.iter().copied().collect(),
+ location: ctx.location,
+ place: match &ctx.crawl {
+ Some(c) => format!("aboard {} in {}", c.name, ctx.system.name),
+ None => format!("{} in {}", ctx.system.places[ctx.location].name, ctx.system.name),
+ },
+ landed: ctx.landed,
+ boarded: ctx.boarded,
+ lenses: ctx.lenses,
+ stripped,
+ hold: SavedHold {
+ scrip: h.scrip,
+ ore: h.ore.iter().map(|(&k, &v)| (k.to_string(), v)).collect(),
+ ore_capacity: h.ore_capacity,
+ gear: Some(h.gear.clone()),
+ lockers: h.lockers.clone(),
+ deck: h.deck.clone(),
+ fuel: h.fuel,
+ fuel_capacity: h.fuel_capacity,
+ debt: h.debt,
+ artifacts: Vec::new(),
+ medpatches: 0,
+ arms: None,
+ },
+ bestiary: ctx.bestiary.iter().map(|&(m, d)| (m.to_string(), d)).collect(),
+ record: ctx.record.clone(),
+ crawl: ctx.crawl.clone(),
+ racks: ctx.racks.clone(),
+ inventory_sort: ctx.inventory_sort,
+ }
+}
+
+/// Starts `ctx` afresh in the save's universe, then puts back everything
+/// the save changed. Anything that doesn't fit this version's universe (an
+/// ore or a monster that no longer exists, a place past the last one) is
+/// dropped or put somewhere sensible rather than refused.
+fn restore(save: Save, ctx: &mut Ctx) {
+ ctx.begin(save.seed, save.character);
+ let star = save.star.filter(|&s| s < ctx.galaxy.sites.len()).unwrap_or(ctx.galaxy.home);
+ if star != ctx.star {
+ ctx.enter_system(star);
+ ctx.location = ctx.system.home_place();
+ }
+ ctx.visited = save.visited.into_iter().filter(|&s| s < ctx.galaxy.sites.len()).collect();
+ ctx.visited.insert(star);
+ let placed = save.location < ctx.system.places.len();
+ if placed {
+ ctx.location = save.location;
+ ctx.landed = save.landed;
+ }
+ if save.day.is_finite() && save.day >= 0.0 {
+ ctx.day = save.day;
+ // Only if you're still where you set out for.
+ ctx.departed = save.departed.filter(|d| placed && (0.0..=ctx.day).contains(d));
+ }
+ ctx.boarded = save.boarded;
+ ctx.lenses = save.lenses;
+ ctx.stripped = save.stripped.into_iter().collect();
+ let mut h = save.hold;
+ let (mut gear, mut lockers) = match h.gear.take() {
+ Some(gear) => (gear, std::mem::take(&mut h.lockers)),
+ None => from_before(&mut h),
+ };
+ gear.mend();
+ mend_pile(&mut lockers);
+ unburden(&mut gear, &mut lockers);
+ let mut deck = h.deck;
+ deck.retain(|(_, s)| s.count > 0);
+ deck.iter_mut().for_each(|(_, s)| mend_stack(s));
+ ctx.hold = Hold {
+ scrip: h.scrip,
+ ore: h
+ .ore
+ .into_iter()
+ .filter_map(|(name, tons)| ORES.iter().find(|o| o.name == name).map(|o| (o.name, tons)))
+ .collect(),
+ ore_capacity: h.ore_capacity.max(Hold::BASE_ORE_CAPACITY),
+ gear,
+ lockers,
+ deck,
+ fuel_capacity: h.fuel_capacity.clamp(Hold::BASE_FUEL, MAX_FUEL),
+ fuel: 0,
+ debt: h.debt,
+ };
+ ctx.hold.fuel = h.fuel.min(ctx.hold.fuel_capacity);
+ ctx.bestiary = save
+ .bestiary
+ .into_iter()
+ .filter_map(|(name, dmg)| MONSTERS.iter().find(|k| k.name == name).map(|k| (k.name, dmg)))
+ .collect();
+ ctx.record = save.record;
+ // A wreck that no longer fits the ship its seed makes is lost, as if you
+ // got out before it came apart: you're back as you were before boarding.
+ ctx.crawl = save.crawl.filter(|c| Crawl::resume(c).is_some());
+ // Only this fortnight's racks, as `trade::rack` keeps: one saved when
+ // racks were restocked every week has a week's number, not a
+ // fortnight's, and could pass for another fortnight's later on.
+ ctx.inventory_sort = save.inventory_sort;
+ ctx.racks = save.racks;
+ ctx.racks.retain(|r| r.fortnight == crate::trade::fortnight(ctx.day));
+ for r in &mut ctx.racks {
+ r.weapons.iter_mut().for_each(Weapon::mend);
+ r.armour.iter_mut().for_each(Armor::mend);
+ }
+}
+
+/// For tests: a folder of their own under the system's temp folder, empty.
+#[cfg(test)]
+pub fn scratch(name: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("orrery-{}-{name}", std::process::id()));
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+ dir
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::dungeon::arms::{self, Source};
+ use crate::names;
+ use crate::seed;
+ use crate::term::KeyboardCaps;
+
+ fn ctx(seed: u64) -> Ctx {
+ Ctx::new(seed, KeyboardCaps { key_release: false })
+ }
+
+ fn character(name: &str) -> Character {
+ Character { name: name.into(), origin: Culture::Drift, ship: "Long Patience".into() }
+ }
+
+ /// A game well under way.
+ fn played(dir: &Path) -> Ctx {
+ let mut ctx = ctx(1312);
+ ctx.begin(1312, character("Ysolde Marr"));
+ ctx.slot = Some(new_slot(dir, &ctx.character, ctx.seed));
+ ctx.day = 41.5;
+ let next = ctx.galaxy.sites[ctx.star].lanes[0];
+ ctx.enter_system(next);
+ ctx.travel(ctx.system.places.len() - 1, 2.5);
+ ctx.landed = false;
+ ctx.boarded = 3;
+ ctx.lenses = 1;
+ ctx.stripped.extend([7, 99]);
+ let mut rng = seed::rng(3);
+ let mut artifact = names::artifact(&mut rng);
+ artifact.identified = true;
+ ctx.hold.lockers.push(Stack::one(Item::Artifact(artifact)));
+ ctx.hold.scrip = 1234;
+ ctx.hold.ore.insert("nickel", 12);
+ ctx.hold.ore_capacity = 60;
+ ctx.hold.gear.add(Stack { item: Item::Medpatch, count: 4 });
+ ctx.hold.fuel_capacity = 6;
+ ctx.hold.fuel = 3;
+ ctx.hold.debt = 75;
+ let blade =
+ (0..).map(|_| arms::find(&mut rng, Source::Armoury, true)).find(|w| !w.is_gun());
+ ctx.hold.gear.add(Stack::one(Item::Weapon(blade.unwrap())));
+ ctx.hold.gear.ready(1).unwrap();
+ ctx.hold.gear.capacity += 10_000;
+ let salvage = items::Salvage { title: "working servo".into(), value: 35, mass: 4000 };
+ ctx.hold.deck.push((Pos::new(20, 9), Stack { item: Item::Salvage(salvage), count: 2 }));
+ ctx.bestiary.insert(("choir-wraith", Dmg::Pierce));
+ ctx.record.boarded = 5;
+ ctx.record.slain.insert("rust-mite".into(), 9);
+ ctx
+ }
+
+ #[test]
+ fn an_armourers_rack_is_as_you_left_it() {
+ let dir = scratch("rack");
+ let mut ctx = played(&dir);
+ let place = ctx.system.home_place();
+ let culture = ctx.system.culture;
+ let rack = crate::trade::rack(&mut ctx.racks, &ctx.system, place, culture, ctx.day);
+ let sold = rack.weapons.remove(0);
+ rack.weapons.push(sold);
+ let mut worn = rack.armour.remove(0);
+ worn.durability /= 2;
+ rack.armour.push(worn);
+ let titles =
+ |c: &Ctx| c.racks[0].weapons.iter().map(|w| w.title.clone()).collect::<Vec<_>>();
+ write(&ctx).unwrap();
+ let mut back = self::ctx(1);
+ load(ctx.slot.as_ref().unwrap(), &mut back).unwrap();
+ assert_eq!(titles(&back), titles(&ctx));
+ assert_eq!(back.racks[0].armour, ctx.racks[0].armour);
+ assert_eq!(back.racks[0].fortnight, ctx.racks[0].fortnight);
+ }
+
+ #[test]
+ fn your_inventory_sort_is_kept_and_an_unknown_one_forgotten() {
+ use crate::scenes::inventory::SortBy;
+ let dir = scratch("sort");
+ let mut ctx = played(&dir);
+ ctx.inventory_sort = Sort { by: SortBy::Density, reversed: true };
+ write(&ctx).unwrap();
+ let mut back = self::ctx(1);
+ load(ctx.slot.as_ref().unwrap(), &mut back).unwrap();
+ assert_eq!(back.inventory_sort, ctx.inventory_sort);
+
+ let mut json: serde_json::Value =
+ serde_json::from_str(&fs::read_to_string(ctx.slot.as_ref().unwrap()).unwrap()).unwrap();
+ json["inventory_sort"] = serde_json::json!({ "by": "vibes", "reversed": true });
+ fs::write(ctx.slot.as_ref().unwrap(), json.to_string()).unwrap();
+ load(ctx.slot.as_ref().unwrap(), &mut back).unwrap();
+ assert_eq!(back.inventory_sort, Sort::default());
+ }
+
+ #[test]
+ fn a_wreck_you_quit_in_is_there_when_you_come_back() {
+ let dir = scratch("wreck");
+ let mut ctx = played(&dir);
+ let mut wreck = crate::dungeon::crawl::Crawl::new(5);
+ wreck.gear = ctx.hold.gear.clone();
+ ctx.crawl = Some(wreck.saved());
+ write(&ctx).unwrap();
+ let summary = &list(&dir)[0].summary;
+ assert!(summary.as_ref().unwrap().place.starts_with("aboard "), "the list says so");
+ let mut back = self::ctx(1);
+ load(ctx.slot.as_ref().unwrap(), &mut back).unwrap();
+ // The same wreck, as you left it (floats aside, which JSON rounds).
+ let resumed = |c: &Ctx| Crawl::resume(c.crawl.as_ref().unwrap()).unwrap();
+ let (was, is) = (resumed(&ctx), resumed(&back));
+ assert_eq!(
+ (is.seed, is.player, is.hp, is.turns),
+ (was.seed, was.player, was.hp, was.turns)
+ );
+ assert_eq!((is.loot.len(), is.monsters.len()), (was.loot.len(), was.monsters.len()));
+ assert_eq!(is.gear.pack, was.gear.pack);
+ let scenes = crate::scenes::flight::resume(&back);
+ assert_eq!(scenes.len(), 2, "flying, with the wreck on top");
+
+ // One that no longer fits its ship is lost, and you're as you were
+ // before you boarded.
+ let mut json: serde_json::Value =
+ serde_json::from_str(&fs::read_to_string(ctx.slot.as_ref().unwrap()).unwrap()).unwrap();
+ json["crawl"]["player"] = serde_json::json!({ "x": -5, "y": -5 });
+ fs::write(ctx.slot.as_ref().unwrap(), json.to_string()).unwrap();
+ load(ctx.slot.as_ref().unwrap(), &mut back).unwrap();
+ assert_eq!(back.crawl, None);
+ assert_eq!(back.hold.gear, ctx.hold.gear);
+ let _ = fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn a_save_brings_back_everything() {
+ let dir = scratch("round-trip");
+ let ctx = played(&dir);
+ write(&ctx).unwrap();
+ let mut back = self::ctx(1);
+ load(ctx.slot.as_ref().unwrap(), &mut back).unwrap();
+ assert_eq!(back.character, ctx.character);
+ assert_eq!((back.seed, back.day, back.location), (ctx.seed, ctx.day, ctx.location));
+ assert_eq!(back.departed, Some(41.5), "the day you set out for here");
+ assert_eq!(back.system.name, ctx.system.name, "the same universe, and system");
+ assert_eq!(back.star, ctx.star);
+ assert_eq!(back.visited, ctx.visited);
+ assert_eq!(back.visited.len(), 2);
+ assert_eq!((back.hold.fuel, back.hold.fuel_capacity, back.hold.debt), (3, 6, 75));
+ assert_eq!((back.landed, back.boarded, back.lenses), (false, 3, 1));
+ assert_eq!(back.stripped, ctx.stripped);
+ assert_eq!(
+ (back.hold.scrip, back.hold.ore_capacity, back.hold.medpatches()),
+ (1234, 60, 4)
+ );
+ assert_eq!(back.hold.ore, ctx.hold.ore);
+ assert_eq!(back.hold.gear, ctx.hold.gear);
+ assert_eq!(back.hold.lockers, ctx.hold.lockers);
+ assert_eq!(back.hold.deck, ctx.hold.deck);
+ assert_eq!(back.bestiary, ctx.bestiary);
+ assert_eq!(back.record, ctx.record);
+ assert_eq!(back.slot, ctx.slot);
+ let _ = fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn what_no_longer_exists_is_dropped() {
+ let dir = scratch("stale");
+ let ctx = played(&dir);
+ let mut save = capture(&ctx);
+ save.hold.ore.insert("unobtainium".into(), 5);
+ save.bestiary.push(("snark".into(), Dmg::Cut));
+ save.location = 10_000;
+ let gear = save.hold.gear.as_mut().unwrap();
+ std::mem::swap(&mut gear.melee, &mut gear.gun);
+ gear.pack.retain(|s| !s.item.is_kit());
+ gear.pack.push(Stack { item: Item::Medpatch, count: 0 });
+ save.hold.lockers.push(Stack::one(Item::Weapon(arms::rivet_gun())));
+ let path = dir.join("stale.json");
+ write_save(&path, &save).unwrap();
+ let mut back = self::ctx(1);
+ load(&path, &mut back).unwrap();
+ assert_eq!(back.hold.ore.len(), 1);
+ assert_eq!(back.bestiary.len(), 1);
+ assert_eq!(back.location, back.system.home_place());
+ let gear = &back.hold.gear;
+ assert!(!gear.melee.is_gun() && gear.gun.is_gun());
+ assert!(gear.pack.iter().all(|s| s.count > 0));
+ let stored = Stack::one(Item::Weapon(arms::rivet_gun()));
+ assert!(back.hold.lockers.contains(&stored), "your own can be stowed");
+ let _ = fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn saves_from_before_the_galaxy_stay_where_they_were() {
+ let dir = scratch("before-galaxy");
+ let mut ctx = played(&dir);
+ // Back in the system the universe began in, wherever the
+ // character's people have their seat.
+ ctx.visited.clear();
+ ctx.enter_system(ctx.galaxy.home);
+ ctx.location = 2;
+ let mut save = capture(&ctx);
+ save.star = None;
+ save.visited.clear();
+ let json = serde_json::to_string(&save).unwrap();
+ let mut json: serde_json::Value = serde_json::from_str(&json).unwrap();
+ for field in ["star", "visited"] {
+ json.as_object_mut().unwrap().remove(field);
+ }
+ for field in ["fuel", "fuel_capacity", "debt"] {
+ json["hold"].as_object_mut().unwrap().remove(field);
+ }
+ let path = dir.join("old.json");
+ fs::write(&path, json.to_string()).unwrap();
+ let mut back = self::ctx(1);
+ load(&path, &mut back).unwrap();
+ assert_eq!(back.star, back.galaxy.home);
+ assert_eq!(back.location, 2);
+ assert_eq!(back.system.name, crate::space::home_system(1312).name);
+ assert_eq!(back.visited.len(), 1);
+ assert_eq!((back.hold.fuel, back.hold.fuel_capacity, back.hold.debt), (4, 4, 0));
+ let _ = fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn saves_from_before_the_inventory_keep_everything() {
+ let dir = scratch("before-inventory");
+ let ctx = played(&dir);
+ let mut json = serde_json::to_value(capture(&ctx)).unwrap();
+ let hold = json["hold"].as_object_mut().unwrap();
+ for field in ["gear", "lockers", "deck"] {
+ hold.remove(field);
+ }
+ let mut rng = seed::rng(8);
+ let artifact = names::artifact(&mut rng);
+ let cleaver =
+ (0..).map(|_| arms::find(&mut rng, Source::Crew, false)).find(|w| !w.is_gun()).unwrap();
+ let mut old = serde_json::to_value(&cleaver).unwrap();
+ old.as_object_mut().unwrap().remove("mass");
+ let torch = serde_json::to_value(arms::cutting_torch()).unwrap();
+ let rivets = serde_json::to_value(arms::rivet_gun()).unwrap();
+ hold.insert("artifacts".into(), serde_json::json!([artifact]));
+ hold.insert("medpatches".into(), 3.into());
+ hold.insert(
+ "arms".into(),
+ serde_json::json!({ "weapons": [torch, rivets, old], "melee": 2, "ranged": 1 }),
+ );
+ json["version"] = 1.into();
+ let path = dir.join("v1.json");
+ fs::write(&path, json.to_string()).unwrap();
+ let mut back = self::ctx(1);
+ load(&path, &mut back).unwrap();
+ let gear = &back.hold.gear;
+ assert_eq!(gear.melee, cleaver, "ready as it was, and weighed");
+ assert!(gear.gun.kit && gear.gun.is_gun());
+ assert_eq!(gear.weapons().count(), 3, "the torch is in the pack");
+ assert_eq!(gear.medpatches(), 3);
+ assert!(back.hold.lockers.contains(&Stack::one(Item::Artifact(artifact))));
+ let _ = fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn unreadable_saves_are_refused_and_change_nothing() {
+ let dir = scratch("unreadable");
+ let garbled = dir.join("garbled.json");
+ fs::write(&garbled, "{ not json").unwrap();
+ let newer = dir.join("newer.json");
+ let mut save = capture(&played(&dir));
+ save.version = VERSION + 1;
+ write_save(&newer, &save).unwrap();
+ let mut ctx = self::ctx(5);
+ ctx.hold.scrip = 77;
+ assert!(load(&garbled, &mut ctx).is_err());
+ assert!(load(&newer, &mut ctx).unwrap_err().contains("newer"));
+ assert_eq!((ctx.seed, ctx.hold.scrip, ctx.slot.is_none()), (5, 77, true));
+ let _ = fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn saves_list_newest_first_and_the_broken_last() {
+ let dir = scratch("list");
+ for (name, saved) in [("Old Hand", 100), ("New Face", 300), ("Middling", 200)] {
+ let mut ctx = played(&dir);
+ ctx.character.name = name.into();
+ let mut save = capture(&ctx);
+ save.saved = saved;
+ write_save(&new_slot(&dir, &ctx.character, ctx.seed), &save).unwrap();
+ }
+ fs::write(dir.join("broken.json"), "[]").unwrap();
+ fs::write(dir.join("notes.txt"), "not a save").unwrap();
+ let slots = list(&dir);
+ let names: Vec<String> = slots
+ .iter()
+ .map(|s| s.summary.as_ref().map_or("?".into(), |s| s.character.name.clone()))
+ .collect();
+ assert_eq!(names, ["New Face", "Middling", "Old Hand", "?"]);
+ assert_eq!(slots[0].summary.as_ref().unwrap().scrip, 1234);
+ let _ = fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn slot_names_are_safe() {
+ let dir = Path::new("/saves");
+ let slot = |name: &str| new_slot(dir, &character(name), 255);
+ assert_eq!(slot("Khaseem ul-Tirun"), dir.join("khaseem-ul-tirun-00000000000000ff.json"));
+ assert_eq!(slot("../../etc"), dir.join("etc-00000000000000ff.json"));
+ assert_eq!(slot("☼☼"), dir.join("character-00000000000000ff.json"));
+ }
+
+ #[test]
+ fn saving_without_a_slot_does_nothing() {
+ assert!(write(&ctx(1)).is_ok());
+ }
+}
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);
+}
diff --git a/src/space/flight.rs b/src/space/flight.rs
new file mode 100644
index 0000000..08f678d
--- /dev/null
+++ b/src/space/flight.rs
@@ -0,0 +1,434 @@
+//! The rules of flight around one place: steering, thrust and drag, the
+//! mining laser, breaking rocks, and scooping up the ore they drop. Nothing
+//! here draws or reads keys; the scene turns keys into `Controls` and draws
+//! the result.
+//!
+//! The feel is arcade rather than physics: you turn on the spot, thrust
+//! pushes along your heading, a little drag stops you drifting forever, and
+//! speed is capped. To stop, turn to face backwards (`Controls::retro` does
+//! that for you) and thrust. Nothing pulls on you; planets and stations are
+//! places to land or dock, not wells to fall into.
+
+use std::f64::consts::TAU;
+
+use rand::RngExt;
+
+use super::locale::{DOCK_RADIUS, Locale, Ore, Rock, Size};
+use super::{Vec2, turn_between};
+use crate::seed::{self, Rng};
+
+/// Units per second per second at full thrust.
+pub const THRUST: f64 = 26.0;
+pub const MAX_SPEED: f64 = 32.0;
+/// The fraction of your speed lost each second.
+const DRAG: f64 = 0.12;
+/// Radians per second.
+pub const TURN_RATE: f64 = 3.6;
+pub const LASER_RANGE: f64 = 24.0;
+/// Seconds before uncollected ore drifts off for good.
+pub const FLOTSAM_LIFE: f64 = 45.0;
+const PICKUP_RANGE: f64 = 2.5;
+/// Land, dock or board only when slower than this.
+pub const LANDING_SPEED: f64 = 8.0;
+/// Board a wreck only when this close.
+pub const BOARDING_RANGE: f64 = 4.0;
+pub const SPARK_LIFE: f64 = 0.45;
+/// How hard the edge of the map pushes you back, per unit you're past it.
+const EDGE_PUSH: f64 = 3.0;
+
+#[derive(Clone, Copy, Debug, Default)]
+pub struct Controls {
+ /// 0 (engines off) to 1 (full).
+ pub thrust: f64,
+ /// -1 (anticlockwise) to 1 (clockwise), as a fraction of `TURN_RATE`.
+ pub turn: f64,
+ /// Turn to face against your direction of travel, ready to brake.
+ pub retro: bool,
+ pub firing: bool,
+}
+
+pub struct Ship {
+ pub pos: Vec2,
+ pub vel: Vec2,
+ /// 0 is up the screen; see `Vec2::from_heading`.
+ pub heading: f64,
+ pub thrusting: bool,
+}
+
+/// Ore floating free after a rock breaks, waiting to be scooped up.
+pub struct Flotsam {
+ pub pos: Vec2,
+ pub vel: Vec2,
+ pub ore: &'static Ore,
+ pub age: f64,
+}
+
+/// A spark of engine exhaust.
+pub struct Spark {
+ pub pos: Vec2,
+ pub vel: Vec2,
+ pub age: f64,
+}
+
+/// The mining laser, as fired this step.
+pub struct Beam {
+ pub from: Vec2,
+ pub to: Vec2,
+ /// Whether it ended on something rather than at full range.
+ pub hit: bool,
+}
+
+#[derive(Debug, PartialEq)]
+pub enum Event {
+ Shattered {
+ size: Size,
+ ore: Option<&'static str>,
+ },
+ Collected(&'static Ore),
+ /// Flew over ore with no room for it.
+ HoldFull,
+}
+
+pub struct Flight {
+ pub locale: Locale,
+ /// Seconds since you arrived.
+ pub time: f64,
+ pub ship: Ship,
+ pub flotsam: Vec<Flotsam>,
+ pub sparks: Vec<Spark>,
+ pub beam: Option<Beam>,
+ /// How fast the laser cuts: 1 as fitted, more with better lenses.
+ pub laser_power: f64,
+ rng: Rng,
+}
+
+impl Flight {
+ /// Arrives at `locale`, facing whatever's in the middle of it.
+ pub fn new(locale: Locale, seed: u64) -> Flight {
+ let pos = locale.arrival;
+ let heading = if pos.length() > 0.0 { (-pos).heading() } else { 0.0 };
+ Flight {
+ locale,
+ time: 0.0,
+ ship: Ship { pos, vel: Vec2::ZERO, heading, thrusting: false },
+ flotsam: Vec::new(),
+ sparks: Vec::new(),
+ beam: None,
+ laser_power: 1.0,
+ rng: seed::rng(seed::derive(seed, "flight", 0)),
+ }
+ }
+
+ pub fn speed(&self) -> f64 {
+ self.ship.vel.length()
+ }
+
+ /// Whether you're over the planet or moon in the middle of the map.
+ pub fn over_body(&self) -> bool {
+ self.locale.body.as_ref().is_some_and(|b| self.ship.pos.length() <= b.radius)
+ }
+
+ /// Whether you're close enough to the station to dock.
+ pub fn at_dock(&self) -> bool {
+ self.locale.dock.is_some() && self.ship.pos.length() <= DOCK_RADIUS
+ }
+
+ /// The wreck close enough to board, if any, as an index into
+ /// `locale.wrecks`.
+ pub fn wreck_alongside(&self) -> Option<usize> {
+ self.locale.wrecks.iter().position(|w| w.pos.distance(self.ship.pos) <= BOARDING_RANGE)
+ }
+
+ /// Whether you're beyond the edge of the map, being pushed back.
+ pub fn past_edge(&self) -> bool {
+ let e = self.locale.extent;
+ self.ship.pos.x.abs() > e || self.ship.pos.y.abs() > e
+ }
+
+ /// Whether you're pointing (near enough) against your direction of travel.
+ pub fn facing_retro(&self) -> bool {
+ let backwards = (-self.ship.vel).heading();
+ self.speed() < 0.5 || turn_between(self.ship.heading, backwards).abs() < 0.05
+ }
+
+ /// Advances the world by `dt` seconds. `room` is how many more tons of
+ /// ore the hold can take.
+ pub fn step(&mut self, dt: f64, controls: Controls, room: u32) -> Vec<Event> {
+ let mut events = Vec::new();
+ self.time += dt;
+ let extent = self.locale.extent;
+ for rock in &mut self.locale.rocks {
+ rock.step(dt, extent);
+ }
+ self.steer(dt, controls);
+ self.fire(dt, controls.firing, &mut events);
+
+ for spark in &mut self.sparks {
+ spark.pos += spark.vel * dt;
+ spark.age += dt;
+ }
+ self.sparks.retain(|s| s.age < SPARK_LIFE);
+
+ let mut collected = 0;
+ for f in &mut self.flotsam {
+ f.pos += f.vel * dt;
+ f.vel = f.vel * (1.0 - 0.3 * dt);
+ f.age += dt;
+ if f.pos.distance(self.ship.pos) <= PICKUP_RANGE {
+ if collected < room {
+ collected += 1;
+ f.age = f64::INFINITY;
+ events.push(Event::Collected(f.ore));
+ } else if !events.contains(&Event::HoldFull) {
+ events.push(Event::HoldFull);
+ }
+ }
+ }
+ self.flotsam.retain(|f| f.age < FLOTSAM_LIFE);
+ events
+ }
+
+ fn steer(&mut self, dt: f64, controls: Controls) {
+ let ship = &mut self.ship;
+ ship.heading += controls.turn.clamp(-1.0, 1.0) * TURN_RATE * dt;
+ if controls.retro && ship.vel.length() > 0.5 {
+ let off = turn_between(ship.heading, (-ship.vel).heading());
+ let most = TURN_RATE * dt;
+ ship.heading += off.clamp(-most, most);
+ }
+ ship.heading = ship.heading.rem_euclid(TAU);
+
+ let ahead = Vec2::from_heading(ship.heading);
+ let thrust = controls.thrust.clamp(0.0, 1.0);
+ ship.thrusting = thrust > 0.0;
+ if ship.thrusting {
+ ship.vel += ahead * (THRUST * thrust * dt);
+ // Exhaust: a spark out the back, thrown off a little to one side.
+ let side = Vec2::new(-ahead.y, ahead.x) * self.rng.random_range(-2.5..2.5);
+ self.sparks.push(Spark {
+ pos: ship.pos - ahead,
+ vel: ship.vel - ahead * self.rng.random_range(10.0..16.0) + side,
+ age: 0.0,
+ });
+ }
+ // The edge of the map: a spring that pushes you back in.
+ let e = self.locale.extent;
+ let over = |v: f64| (v.abs() - e).max(0.0) * v.signum();
+ ship.vel -= Vec2::new(over(ship.pos.x), over(ship.pos.y)) * (EDGE_PUSH * dt);
+
+ ship.vel = ship.vel * (1.0 - DRAG * dt);
+ if ship.vel.length() > MAX_SPEED {
+ ship.vel = ship.vel.normalised() * MAX_SPEED;
+ }
+ ship.pos += ship.vel * dt;
+ }
+
+ /// Casts the laser along your heading, wearing down the first rock it
+ /// meets. The planet or station in the middle stops it too, unharmed.
+ fn fire(&mut self, dt: f64, firing: bool, events: &mut Vec<Event>) {
+ self.beam = None;
+ if !firing {
+ return;
+ }
+ let ahead = Vec2::from_heading(self.ship.heading);
+ let from = self.ship.pos + ahead;
+ let mut nearest: Option<(f64, Option<usize>)> = None;
+ let mut consider = |centre: Vec2, r: f64, what: Option<usize>| {
+ if let Some(d) = ray_hits_circle(from, ahead, centre, r)
+ && d <= LASER_RANGE
+ && nearest.is_none_or(|(n, _)| d < n)
+ {
+ nearest = Some((d, what));
+ }
+ };
+ for (i, rock) in self.locale.rocks.iter().enumerate() {
+ consider(rock.pos, rock.size.radius(), Some(i));
+ }
+ if let Some(r) = self.locale.solid_radius() {
+ consider(Vec2::ZERO, r, None);
+ }
+ let reach = nearest.map_or(LASER_RANGE, |(d, _)| d);
+ self.beam = Some(Beam { from, to: from + ahead * reach, hit: nearest.is_some() });
+ if let Some((_, Some(i))) = nearest {
+ self.locale.rocks[i].integrity -= dt * self.laser_power;
+ if self.locale.rocks[i].integrity <= 0.0 {
+ self.shatter(i, events);
+ }
+ }
+ }
+
+ /// Breaks a rock into two smaller ones drifting apart, and spills its
+ /// ore, if it had any.
+ fn shatter(&mut self, i: usize, events: &mut Vec<Event>) {
+ let rock = self.locale.rocks.swap_remove(i);
+ let rng = &mut self.rng;
+ let vel = rock.velocity();
+ if let Some(size) = rock.size.smaller() {
+ let apart = Vec2::from_heading(rng.random_range(0.0..TAU));
+ for side in [-1.0, 1.0] {
+ let pos = rock.pos + apart * (side * rock.size.radius() * 0.6);
+ let child = if rock.circling == 0.0 {
+ let kick = apart * (side * rng.random_range(0.5..1.5));
+ Rock::new(pos, rock.vel + kick, 0.0, size, rock.ore)
+ } else {
+ // Keep circling at about the parent's distance.
+ let circling = rock.circling * rng.random_range(0.9..1.1);
+ Rock::new(pos, Vec2::ZERO, circling, size, rock.ore)
+ };
+ self.locale.rocks.push(child);
+ }
+ }
+ if let Some(ore) = rock.ore {
+ let chunks = match rock.size {
+ Size::Small => 1,
+ Size::Medium => 2,
+ Size::Large => 3,
+ };
+ for _ in 0..chunks {
+ let drift =
+ Vec2::from_heading(rng.random_range(0.0..TAU)) * rng.random_range(1.0..4.0);
+ self.flotsam.push(Flotsam { pos: rock.pos, vel: vel + drift, ore, age: 0.0 });
+ }
+ }
+ events.push(Event::Shattered { size: rock.size, ore: rock.ore.map(|o| o.name) });
+ }
+}
+
+/// How far along a ray (from `from`, along unit vector `dir`) it first
+/// touches a circle, if it does. Zero if it starts inside.
+fn ray_hits_circle(from: Vec2, dir: Vec2, centre: Vec2, r: f64) -> Option<f64> {
+ let to_centre = centre - from;
+ let along = to_centre.dot(dir);
+ let miss_sq = to_centre.dot(to_centre) - along * along;
+ if along < -r || miss_sq > r * r {
+ return None;
+ }
+ Some((along - (r * r - miss_sq).sqrt()).max(0.0))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::space::locale::{self, ORES};
+ use crate::space::system::{self, PlaceKind};
+
+ const DT: f64 = 1.0 / 30.0;
+
+ /// Flying in the belt of system 1, with the rocks cleared away.
+ fn flight() -> Flight {
+ let s = system::generate(1);
+ let belt = s.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap();
+ let mut locale = locale::generate(&s, belt, 0.0, None, &Default::default());
+ locale.rocks.clear();
+ Flight::new(locale, 1)
+ }
+
+ fn run(f: &mut Flight, seconds: f64, controls: Controls) -> Vec<Event> {
+ let mut events = Vec::new();
+ for _ in 0..(seconds / DT).round() as usize {
+ events.extend(f.step(DT, controls, 99));
+ }
+ events
+ }
+
+ /// A rock that sits still at `pos`.
+ fn rock_at(pos: Vec2, size: Size) -> Rock {
+ Rock::new(pos, Vec2::ZERO, 0.0, size, Some(&ORES[0]))
+ }
+
+ #[test]
+ fn thrust_pushes_along_the_heading_up_to_a_limit() {
+ let mut f = flight();
+ f.ship.heading = TAU / 4.0;
+ run(&mut f, 1.0, Controls { thrust: 1.0, ..Controls::default() });
+ assert!(f.ship.vel.x > 15.0 && f.ship.vel.y.abs() < 1e-6, "heading right goes right");
+ run(&mut f, 3.0, Controls { thrust: 1.0, ..Controls::default() });
+ assert!((f.speed() - MAX_SPEED).abs() < 1e-6, "speed is capped");
+ }
+
+ #[test]
+ fn drag_slows_you_when_coasting() {
+ let mut f = flight();
+ f.ship.vel = Vec2::new(20.0, 0.0);
+ run(&mut f, 3.0, Controls::default());
+ assert!(f.speed() < 15.0 && f.speed() > 5.0, "coasting decays, slowly: {}", f.speed());
+ }
+
+ #[test]
+ fn retro_turns_you_to_face_backwards() {
+ let mut f = flight();
+ f.ship.vel = Vec2::new(0.0, -10.0);
+ f.ship.heading = 0.0;
+ assert!(!f.facing_retro());
+ run(&mut f, 1.5, Controls { retro: true, ..Controls::default() });
+ assert!(f.facing_retro(), "heading {} after retro", f.ship.heading);
+ }
+
+ #[test]
+ fn the_edge_pushes_you_back() {
+ let mut f = flight();
+ f.ship.pos = Vec2::new(f.locale.extent - 5.0, 0.0);
+ f.ship.vel = Vec2::new(MAX_SPEED, 0.0);
+ run(&mut f, 5.0, Controls::default());
+ assert!(f.ship.vel.x < 0.0, "turned round");
+ assert!(f.ship.pos.x < f.locale.extent + 30.0);
+ }
+
+ #[test]
+ fn mining_breaks_rocks_and_ore_can_be_scooped_up() {
+ let mut f = flight();
+ f.ship.pos = Vec2::new(50.0, 0.0);
+ f.ship.heading = 0.0;
+ f.locale.rocks = vec![rock_at(Vec2::new(50.0, -10.0), Size::Medium)];
+ // Long enough to break the rock, not long enough to break its pieces.
+ let events = run(&mut f, 1.1, Controls { firing: true, ..Controls::default() });
+ assert!(events.contains(&Event::Shattered { size: Size::Medium, ore: Some("iron ore") }));
+ assert_eq!(f.locale.rocks.len(), 2, "a medium rock breaks in two");
+ assert!(f.locale.rocks.iter().all(|r| r.size == Size::Small));
+ assert_eq!(f.flotsam.len(), 2, "a medium rock spills two chunks");
+
+ f.ship.pos = f.flotsam[0].pos;
+ let events = f.step(DT, Controls::default(), 1);
+ assert!(events.iter().any(|e| matches!(e, Event::Collected(_))));
+ }
+
+ #[test]
+ fn a_full_hold_leaves_ore_floating() {
+ let mut f = flight();
+ f.flotsam = vec![Flotsam { pos: f.ship.pos, vel: Vec2::ZERO, ore: &ORES[0], age: 0.0 }];
+ let events = f.step(DT, Controls::default(), 0);
+ assert_eq!(events, vec![Event::HoldFull]);
+ assert_eq!(f.flotsam.len(), 1);
+ }
+
+ #[test]
+ fn the_laser_stops_at_the_first_rock() {
+ let mut f = flight();
+ f.ship.pos = Vec2::new(50.0, 0.0);
+ f.ship.heading = 0.0;
+ f.locale.rocks = vec![
+ rock_at(Vec2::new(50.0, -15.0), Size::Small),
+ rock_at(Vec2::new(50.0, -6.0), Size::Small),
+ ];
+ f.step(DT, Controls { firing: true, ..Controls::default() }, 0);
+ let beam = f.beam.as_ref().unwrap();
+ assert!(beam.hit && beam.to.y > -6.0 && beam.to.y < -5.0, "beam ends at {:?}", beam.to);
+ assert!(f.locale.rocks[1].integrity < f.locale.rocks[0].integrity);
+ }
+
+ #[test]
+ fn planets_stop_the_laser_and_can_be_landed_on() {
+ let s = system::generate(1);
+ let mut f =
+ Flight::new(locale::generate(&s, s.home_place(), 0.0, None, &Default::default()), 1);
+ f.locale.rocks.clear();
+ let radius = f.locale.body.as_ref().unwrap().radius;
+ assert!(!f.over_body());
+ f.step(DT, Controls { firing: true, ..Controls::default() }, 0);
+ let beam = f.beam.as_ref().unwrap();
+ assert!(beam.hit, "you arrive facing the planet, in range");
+ assert!((beam.to.length() - radius).abs() < 1e-6);
+ f.ship.pos = Vec2::new(0.0, radius - 1.0);
+ assert!(f.over_body());
+ }
+}
diff --git a/src/space/galaxy.rs b/src/space/galaxy.rs
new file mode 100644
index 0000000..bb06667
--- /dev/null
+++ b/src/space/galaxy.rs
@@ -0,0 +1,573 @@
+//! The galaxy: about ninety star systems, the lanes between them, and the
+//! four peoples' territories.
+//!
+//! You can't fly between systems; you jump, and only along a lane. Lanes
+//! join near neighbours: every pair in the relative neighbourhood graph
+//! (no third star closer to both than they are to each other), which keeps
+//! the map connected and its lanes from crossing, plus some of the extra
+//! pairs of the Gabriel graph, for a few loops. To reach a far system you
+//! jump through the ones between.
+//!
+//! Stars are scattered across a lopsided disc, denser in some places than
+//! others, with a few empty voids that the lanes have to bridge.
+//!
+//! Each people has a seat: the system the universe started you in is one,
+//! for whoever holds it, and the other three are spread out from it.
+//! Territory grows from each seat along the lanes, each people at its own
+//! pace, so territories come out in ragged blobs of different sizes. Every
+//! system ends up held by someone.
+//!
+//! What a system is like (its kind) depends on where it lies: settled
+//! systems around the seats, frontier and desolate ones further out, and
+//! old battlefields along the borders, where the neighbours settle too.
+//!
+//! A galaxy only knows what it takes to draw the map. Each system is
+//! generated whole when you need it, from its `Site`.
+
+use std::cmp::Reverse;
+use std::collections::{BTreeSet, BinaryHeap};
+use std::f64::consts::TAU;
+
+use rand::RngExt;
+use rand::seq::{IndexedRandom, SliceRandom};
+
+use super::Vec2;
+use super::system::{self, Profile, StarKind, System, SystemKind};
+use crate::names::Culture;
+use crate::seed::{self, Rng};
+
+/// How many systems a galaxy has, if they all fit.
+pub const SYSTEMS: usize = 90;
+/// Half the galaxy's width and height, in light-years.
+pub const HALF_WIDTH: f64 = 100.0;
+pub const HALF_HEIGHT: f64 = 62.0;
+/// No two stars are closer than this, in light-years.
+const SPACING: f64 = 9.0;
+/// Lanes per system, at most, counting only the extra loops.
+const MAX_LANES: usize = 4;
+
+/// A star system as the map knows it, before it's generated.
+pub struct Site {
+ pub name: String,
+ /// In light-years from the middle of the galaxy, y up.
+ pub pos: Vec2,
+ /// Who holds it.
+ pub culture: Culture,
+ pub kind: SystemKind,
+ pub star: &'static StarKind,
+ /// The systems a lane joins it to.
+ pub lanes: Vec<usize>,
+ /// For a system on a border: the people across it.
+ pub neighbour: Option<Culture>,
+ seed: u64,
+}
+
+pub struct Galaxy {
+ pub sites: Vec<Site>,
+ /// The system the universe started everyone in, before there was a
+ /// galaxy. Saves from then are there.
+ pub home: usize,
+ /// Each people's seat, in `Culture::ALL` order.
+ seats: [usize; 4],
+}
+
+impl Galaxy {
+ /// Generates a system in full.
+ pub fn system(&self, site: usize) -> System {
+ let s = &self.sites[site];
+ if site == self.home {
+ return system::generate(s.seed);
+ }
+ let profile = Profile {
+ name: s.name.clone(),
+ culture: s.culture,
+ star: s.star,
+ kind: s.kind,
+ neighbour: s.neighbour,
+ };
+ system::generate_as(s.seed, &profile)
+ }
+
+ /// The seat of a people.
+ pub fn seat(&self, culture: Culture) -> usize {
+ self.seats[culture as usize]
+ }
+
+ pub fn is_seat(&self, site: usize) -> bool {
+ self.seats.contains(&site)
+ }
+
+ /// Light-years between two systems.
+ pub fn distance(&self, a: usize, b: usize) -> f64 {
+ self.sites[a].pos.distance(self.sites[b].pos)
+ }
+
+ /// Days a jump takes from one system to another, along a lane.
+ pub fn jump_days(&self, a: usize, b: usize) -> f64 {
+ 1.0 + self.distance(a, b) / 5.0
+ }
+
+ /// Days to jump along a route from `from`.
+ pub fn route_days(&self, from: usize, route: &[usize]) -> f64 {
+ let mut at = from;
+ let mut days = 0.0;
+ for &next in route {
+ days += self.jump_days(at, next);
+ at = next;
+ }
+ days
+ }
+
+ /// The systems you know the way to: those you've been to, and those a
+ /// lane joins to them.
+ pub fn charted(&self, visited: &BTreeSet<usize>) -> Vec<bool> {
+ let mut charted = vec![false; self.sites.len()];
+ for &v in visited.iter().filter(|&&v| v < self.sites.len()) {
+ charted[v] = true;
+ for &n in &self.sites[v].lanes {
+ charted[n] = true;
+ }
+ }
+ charted
+ }
+
+ /// The fewest jumps from `from` to `to`, going only through systems
+ /// that are `passable`, and the shortest such way. Doesn't include
+ /// `from`, ends with `to`, and is empty if they're the same.
+ pub fn route(
+ &self,
+ from: usize,
+ to: usize,
+ passable: impl Fn(usize) -> bool,
+ ) -> Option<Vec<usize>> {
+ self.search(from, passable, |s| s == to)
+ }
+
+ /// The fewest jumps to the nearest system that is `wanted`, through any
+ /// system at all.
+ pub fn nearest(&self, from: usize, wanted: impl Fn(usize) -> bool) -> Option<Vec<usize>> {
+ self.search(from, |_| true, wanted)
+ }
+
+ fn search(
+ &self,
+ from: usize,
+ passable: impl Fn(usize) -> bool,
+ goal: impl Fn(usize) -> bool,
+ ) -> Option<Vec<usize>> {
+ let n = self.sites.len();
+ // Jumps first, then distance, in hundredths of a light-year.
+ let cost = |jumps: u64, ly: f64| jumps * 1_000_000_000 + (ly * 100.0) as u64;
+ let mut best = vec![u64::MAX; n];
+ let mut came_from = vec![usize::MAX; n];
+ let mut queue = BinaryHeap::new();
+ best[from] = 0;
+ queue.push(Reverse((0, 0u64, 0.0f64.to_bits(), from)));
+ while let Some(Reverse((c, jumps, ly, at))) = queue.pop() {
+ if c > best[at] {
+ continue;
+ }
+ if goal(at) {
+ let mut route = vec![];
+ let mut step = at;
+ while step != from {
+ route.push(step);
+ step = came_from[step];
+ }
+ route.reverse();
+ return Some(route);
+ }
+ for &next in &self.sites[at].lanes {
+ if !passable(next) {
+ continue;
+ }
+ let ly = f64::from_bits(ly) + self.distance(at, next);
+ let c = cost(jumps + 1, ly);
+ if c < best[next] {
+ best[next] = c;
+ came_from[next] = at;
+ queue.push(Reverse((c, jumps + 1, ly.to_bits(), next)));
+ }
+ }
+ }
+ None
+ }
+
+ /// The corners of a box round every star, in light-years.
+ pub fn bounds(&self) -> (Vec2, Vec2) {
+ let mut low = Vec2::new(f64::MAX, f64::MAX);
+ let mut high = Vec2::new(f64::MIN, f64::MIN);
+ for s in &self.sites {
+ low = Vec2::new(low.x.min(s.pos.x), low.y.min(s.pos.y));
+ high = Vec2::new(high.x.max(s.pos.x), high.y.max(s.pos.y));
+ }
+ (low, high)
+ }
+}
+
+pub fn generate(universe: u64) -> Galaxy {
+ let home_seed = seed::derive(universe, "system", 0);
+ let home_system = system::generate(home_seed);
+ let mut rng = seed::rng(seed::derive(universe, "galaxy", 0));
+
+ let points = scatter(&mut rng);
+ let n = points.len();
+ let lanes = join(&points, &mut rng);
+
+ // The seats, as far apart as can be without crowding the rim.
+ let seat_sites = place_seats(&points, &mut rng);
+ let mut cultures = Culture::ALL;
+ cultures.shuffle(&mut rng);
+ // Whoever holds the original system has their seat there.
+ let mut seats = [0; 4];
+ for (&site, &culture) in seat_sites.iter().zip(&cultures) {
+ seats[culture as usize] = site;
+ }
+ let home = seats[home_system.culture as usize];
+
+ let owner = grow(&lanes, &points, &seats, universe, &mut rng);
+
+ // What each system is, given where it lies.
+ let hops_from_seat = |i: usize| {
+ let seat = seats[owner[i] as usize];
+ if i == seat {
+ 0
+ } else if lanes[i].contains(&seat) {
+ 1
+ } else {
+ 2
+ }
+ };
+ let mut names: BTreeSet<String> = BTreeSet::new();
+ names.insert(home_system.name.clone());
+ let mut sites = Vec::with_capacity(n);
+ for i in 0..n {
+ let culture = owner[i];
+ let mut across: Vec<Culture> =
+ lanes[i].iter().map(|&j| owner[j]).filter(|&c| c != culture).collect();
+ across.sort_by_key(|&c| c as usize);
+ // The people most of its foreign lanes lead to.
+ let neighbour = Culture::ALL
+ .into_iter()
+ .filter(|c| across.contains(c))
+ .max_by_key(|&c| across.iter().filter(|&&x| x == c).count());
+ let kind = if seats.contains(&i) {
+ SystemKind::Seat
+ } else {
+ pick_kind(hops_from_seat(i), neighbour.is_some(), lanes[i].len() == 1, &mut rng)
+ };
+ let (name, star, seed) = if i == home {
+ (home_system.name.clone(), home_system.star, home_seed)
+ } else {
+ let name = loop {
+ let name = culture.word(&mut rng);
+ if names.insert(name.clone()) {
+ break name;
+ }
+ };
+ let star = system::random_star(kind, &mut rng);
+ (name, star, seed::derive(universe, "system", i as u64 + 1))
+ };
+ sites.push(Site {
+ name,
+ pos: points[i],
+ culture,
+ kind,
+ star,
+ lanes: lanes[i].clone(),
+ neighbour,
+ seed,
+ });
+ }
+ Galaxy { sites, home, seats }
+}
+
+/// Where the stars are: scattered over a lopsided disc, clumped here and
+/// thin there, with a few empty voids.
+fn scatter(rng: &mut Rng) -> Vec<Vec2> {
+ let lobes = [
+ (rng.random_range(0.06..0.14), rng.random_range(0.0..TAU)),
+ (rng.random_range(0.03..0.08), rng.random_range(0.0..TAU)),
+ ];
+ let inside = |p: Vec2| {
+ let (x, y) = (p.x / HALF_WIDTH, p.y / HALF_HEIGHT);
+ let angle = y.atan2(x);
+ let edge = 1.0
+ + lobes[0].0 * (2.0 * angle + lobes[0].1).sin()
+ + lobes[1].0 * (3.0 * angle + lobes[1].1).sin();
+ x.hypot(y) < edge
+ };
+ let voids: Vec<(Vec2, f64)> = (0..rng.random_range(2..=3))
+ .map(|_| {
+ let angle = rng.random_range(0.0..TAU);
+ let r = rng.random_range(0.2..0.7);
+ let at = Vec2::new(angle.cos() * r * HALF_WIDTH, angle.sin() * r * HALF_HEIGHT);
+ (at, rng.random_range(9.0..15.0))
+ })
+ .collect();
+ let phase = (rng.random_range(0.0..TAU), rng.random_range(0.0..TAU));
+ let clumping = |p: Vec2| {
+ let wave = (p.x / 23.0 + phase.0).sin() * (p.y / 17.0 + phase.1).sin();
+ 0.45 + 0.55 * (0.5 + 0.5 * wave)
+ };
+ let mut points: Vec<Vec2> = Vec::new();
+ for _ in 0..200_000 {
+ if points.len() == SYSTEMS {
+ break;
+ }
+ let p = Vec2::new(
+ rng.random_range(-1.2 * HALF_WIDTH..1.2 * HALF_WIDTH),
+ rng.random_range(-1.2 * HALF_HEIGHT..1.2 * HALF_HEIGHT),
+ );
+ if !inside(p)
+ || !rng.random_bool(clumping(p))
+ || voids.iter().any(|&(c, r)| p.distance(c) < r)
+ || points.iter().any(|q| q.distance(p) < SPACING)
+ {
+ continue;
+ }
+ points.push(p);
+ }
+ points
+}
+
+/// The lanes: every pair in the relative neighbourhood graph, and some of
+/// the Gabriel graph's extra pairs, shortest first. Each system's list is
+/// sorted, so it's the same every time.
+fn join(points: &[Vec2], rng: &mut Rng) -> Vec<Vec<usize>> {
+ let n = points.len();
+ let d = |i: usize, j: usize| points[i].distance(points[j]);
+ let mut lanes = vec![Vec::new(); n];
+ let mut extras = Vec::new();
+ for i in 0..n {
+ for j in i + 1..n {
+ let dij = d(i, j);
+ let others = (0..n).filter(|&k| k != i && k != j);
+ if !others.clone().any(|k| d(i, k).max(d(j, k)) < dij) {
+ lanes[i].push(j);
+ lanes[j].push(i);
+ } else if !others.into_iter().any(|k| d(i, k).powi(2) + d(j, k).powi(2) < dij * dij) {
+ extras.push((dij, i, j));
+ }
+ }
+ }
+ extras.sort_by(|a, b| a.0.total_cmp(&b.0));
+ for (_, i, j) in extras {
+ if lanes[i].len() < MAX_LANES && lanes[j].len() < MAX_LANES && rng.random_bool(0.45) {
+ lanes[i].push(j);
+ lanes[j].push(i);
+ }
+ }
+ for l in &mut lanes {
+ l.sort_unstable();
+ }
+ lanes
+}
+
+/// Four seats spread as far apart as they'll go, none of them out on the
+/// very rim.
+fn place_seats(points: &[Vec2], rng: &mut Rng) -> [usize; 4] {
+ let rim = |p: Vec2| (p.x / HALF_WIDTH).hypot(p.y / HALF_HEIGHT);
+ let mut pool: Vec<usize> = (0..points.len()).filter(|&i| rim(points[i]) < 0.7).collect();
+ if pool.len() < 4 {
+ pool = (0..points.len()).collect();
+ }
+ let mut best = [0, 1, 2, 3];
+ let mut best_gap = f64::MIN;
+ for _ in 0..400 {
+ pool.shuffle(rng);
+ let pick = [pool[0], pool[1], pool[2], pool[3]];
+ let mut gap = f64::MAX;
+ for a in 0..4 {
+ for b in a + 1..4 {
+ gap = gap.min(points[pick[a]].distance(points[pick[b]]));
+ }
+ }
+ if gap > best_gap {
+ best_gap = gap;
+ best = pick;
+ }
+ }
+ best
+}
+
+/// Who holds each system: each people spreads out from its seat along the
+/// lanes, at a pace of its own, and claims whatever it reaches first.
+fn grow(
+ lanes: &[Vec<usize>],
+ points: &[Vec2],
+ seats: &[usize; 4],
+ universe: u64,
+ rng: &mut Rng,
+) -> Vec<Culture> {
+ let pace: Vec<f64> = (0..4).map(|_| rng.random_range(0.75..1.3)).collect();
+ let mut owner: Vec<Option<Culture>> = vec![None; points.len()];
+ let mut queue = BinaryHeap::new();
+ for c in Culture::ALL {
+ queue.push(Reverse((0u64, seats[c as usize], c as usize)));
+ }
+ while let Some(Reverse((cost, at, c))) = queue.pop() {
+ if owner[at].is_some() {
+ continue;
+ }
+ owner[at] = Some(Culture::ALL[c]);
+ for &next in &lanes[at] {
+ if owner[next].is_some() {
+ continue;
+ }
+ // Some lanes are harder going for some peoples than others.
+ let rough = seed::derive(universe, "border", (at * points.len() + next) as u64 * 4);
+ let rough = 0.65 + 0.8 * ((rough >> 11) as f64 / (1u64 << 53) as f64);
+ let step = points[at].distance(points[next]) * pace[c] * rough;
+ queue.push(Reverse((cost + (step * 1000.0) as u64, next, c)));
+ }
+ }
+ // The lanes join everything, so everything is reached; but just in case.
+ owner.into_iter().map(|o| o.unwrap_or(Culture::Drift)).collect()
+}
+
+/// A kind for a system `hops` from its own seat (two meaning two or more),
+/// maybe on a border, maybe at the end of a lane to nowhere else.
+fn pick_kind(hops: u32, border: bool, dead_end: bool, rng: &mut Rng) -> SystemKind {
+ use SystemKind::*;
+ let mut weights: Vec<(SystemKind, u32)> = if hops <= 1 {
+ vec![(Settled, 70), (Frontier, 8), (Rubble, 16), (Desolate, 4), (Graveyard, 2)]
+ } else if border {
+ vec![(Settled, 22), (Frontier, 22), (Rubble, 12), (Desolate, 20), (Graveyard, 24)]
+ } else {
+ vec![(Settled, 40), (Frontier, 26), (Rubble, 16), (Desolate, 14), (Graveyard, 4)]
+ };
+ if dead_end {
+ for (kind, w) in &mut weights {
+ if matches!(kind, Frontier | Desolate) {
+ *w *= 2;
+ }
+ }
+ }
+ weights.choose_weighted(rng, |&(_, w)| w).unwrap().0
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::space::system::PlaceKind;
+
+ #[test]
+ fn galaxies_are_whole() {
+ for universe in 0..12 {
+ let g = generate(universe);
+ let n = g.sites.len();
+ assert!(n >= SYSTEMS - 5, "universe {universe}: only {n} systems");
+ // Every system can be reached from every other.
+ for to in 0..n {
+ assert!(g.route(g.home, to, |_| true).is_some(), "universe {universe}: {to}");
+ }
+ // Lanes go both ways, and no star sits on top of another.
+ for (i, s) in g.sites.iter().enumerate() {
+ assert!(!s.lanes.is_empty());
+ for &j in &s.lanes {
+ assert!(g.sites[j].lanes.contains(&i));
+ }
+ for j in i + 1..n {
+ assert!(g.distance(i, j) >= SPACING);
+ }
+ }
+ let names: BTreeSet<&str> = g.sites.iter().map(|s| s.name.as_str()).collect();
+ assert_eq!(names.len(), n, "universe {universe}: names repeat");
+ }
+ }
+
+ #[test]
+ fn each_people_holds_one_patch_round_its_seat() {
+ for universe in 0..12 {
+ let g = generate(universe);
+ for c in Culture::ALL {
+ let seat = g.seat(c);
+ assert_eq!(g.sites[seat].culture, c);
+ assert_eq!(g.sites[seat].kind, SystemKind::Seat);
+ // Every system they hold is reachable without leaving
+ // their space.
+ for (i, s) in g.sites.iter().enumerate().filter(|(_, s)| s.culture == c) {
+ let inside = g.route(seat, i, |j| g.sites[j].culture == c);
+ assert!(inside.is_some(), "universe {universe}: {} cut off", s.name);
+ }
+ let held = g.sites.iter().filter(|s| s.culture == c).count();
+ assert!(held >= 6, "universe {universe}: {} hold only {held}", c.name());
+ }
+ }
+ }
+
+ #[test]
+ fn the_original_system_is_its_peoples_seat() {
+ for universe in [0, 7, 1312] {
+ let g = generate(universe);
+ let old = crate::space::home_system(universe);
+ let s = g.system(g.home);
+ assert_eq!(s.name, old.name);
+ assert_eq!(s.places.len(), old.places.len());
+ assert_eq!(g.seat(old.culture), g.home);
+ assert_eq!(g.sites[g.home].name, old.name);
+ }
+ }
+
+ #[test]
+ fn systems_are_what_the_map_says() {
+ let g = generate(3);
+ for (i, site) in g.sites.iter().enumerate() {
+ let s = g.system(i);
+ assert_eq!(
+ (s.name.as_str(), s.culture, s.kind),
+ (site.name.as_str(), site.culture, site.kind)
+ );
+ assert_eq!(s.star.name, site.star.name);
+ assert_eq!(s.sells_fuel(), site.kind.sells_fuel(), "{}: {:?}", s.name, s.kind);
+ assert!(matches!(
+ s.places[s.edge_place()].kind,
+ PlaceKind::Planet(_) | PlaceKind::Belt
+ ));
+ }
+ }
+
+ #[test]
+ fn there_is_variety() {
+ let mut seen = BTreeSet::new();
+ for universe in 0..4 {
+ for s in generate(universe).sites {
+ seen.insert(format!("{:?}", s.kind));
+ }
+ }
+ assert_eq!(seen.len(), SystemKind::ALL.len(), "{seen:?}");
+ }
+
+ #[test]
+ fn routes_take_the_fewest_jumps_through_what_you_allow() {
+ let g = generate(5);
+ let far = (0..g.sites.len())
+ .max_by(|&a, &b| g.distance(g.home, a).total_cmp(&g.distance(g.home, b)));
+ let far = far.unwrap();
+ let route = g.route(g.home, far, |_| true).unwrap();
+ assert!(route.len() >= 3, "the far side is several jumps away");
+ assert_eq!(*route.last().unwrap(), far);
+ for pair in
+ std::iter::once(g.home).chain(route.iter().copied()).collect::<Vec<_>>().windows(2)
+ {
+ assert!(g.sites[pair[0]].lanes.contains(&pair[1]), "jumps follow lanes");
+ }
+ assert_eq!(g.route(g.home, g.home, |_| true), Some(vec![]));
+ assert_eq!(g.route(g.home, far, |s| s == g.home), None, "blocked");
+ let mut visited = BTreeSet::new();
+ visited.insert(g.home);
+ let charted = g.charted(&visited);
+ assert_eq!(charted.iter().filter(|&&c| c).count(), 1 + g.sites[g.home].lanes.len());
+ }
+
+ #[test]
+ fn same_universe_same_galaxy() {
+ let a = generate(42);
+ let b = generate(42);
+ let key = |g: &Galaxy| {
+ g.sites.iter().map(|s| (s.name.clone(), s.lanes.clone(), s.kind)).collect::<Vec<_>>()
+ };
+ assert_eq!(key(&a), key(&b));
+ }
+}
diff --git a/src/space/locale.rs b/src/space/locale.rs
new file mode 100644
index 0000000..d6339b9
--- /dev/null
+++ b/src/space/locale.rs
@@ -0,0 +1,406 @@
+//! The local map of one place: what you fly around in once you arrive.
+//!
+//! A planet's orbit has the planet filling the middle of the map, a few
+//! rocks circling it, and its moons hanging far off in the sky. A moon is
+//! the same, smaller, with its planet looming behind. The belt is all rocks.
+//! A station sits among the Trojan asteroids that collect at its Lagrange
+//! point.
+//!
+//! The layout (which way things lie) is fixed for each place. The rocks are
+//! regenerated for each day you visit, so a mined-out place fills up again
+//! if you come back another day. The wrecks are whichever are calling that
+//! day, and any that were calling when you set out for the place, less
+//! those you've stripped: a hulk you've emptied is still there while you
+//! are, and gone when you come back. Each wreck keeps its own spot for as
+//! long as it lasts. The star lights everything from the direction it
+//! really is in on that date.
+
+use std::collections::HashSet;
+use std::f64::consts::TAU;
+
+use rand::RngExt;
+use rand::seq::IndexedRandom;
+
+use super::Vec2;
+use super::system::{BodyKind, PlaceKind, Rgb, System, Wreck};
+use crate::names::{self, Culture};
+use crate::seed::{self, Rng};
+
+#[derive(Debug, PartialEq)]
+pub struct Ore {
+ pub name: &'static str,
+ /// Scrip per ton, before local prices.
+ pub value: u32,
+ pub rgb: Rgb,
+ weight: u32,
+}
+
+#[rustfmt::skip]
+pub const ORES: &[Ore] = &[
+ Ore { name: "iron ore", value: 4, rgb: (178, 112, 88), weight: 10 },
+ Ore { name: "nickel", value: 6, rgb: (176, 182, 160), weight: 7 },
+ Ore { name: "iridium", value: 14, rgb: (200, 214, 236), weight: 4 },
+ Ore { name: "saltglass", value: 22, rgb: (240, 222, 196), weight: 2 },
+ Ore { name: "vesper crystal", value: 35, rgb: (196, 124, 255), weight: 1 },
+];
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Size {
+ Small,
+ Medium,
+ Large,
+}
+
+impl Size {
+ pub fn radius(self) -> f64 {
+ match self {
+ Size::Small => 0.8,
+ Size::Medium => 1.6,
+ Size::Large => 2.6,
+ }
+ }
+
+ /// Seconds of laser fire to break it.
+ pub fn toughness(self) -> f64 {
+ match self {
+ Size::Small => 0.4,
+ Size::Medium => 1.0,
+ Size::Large => 2.0,
+ }
+ }
+
+ /// What it breaks into, if anything.
+ pub fn smaller(self) -> Option<Size> {
+ match self {
+ Size::Small => None,
+ Size::Medium => Some(Size::Small),
+ Size::Large => Some(Size::Medium),
+ }
+ }
+}
+
+pub struct Rock {
+ pub pos: Vec2,
+ /// Drift in a straight line, in units per second.
+ pub vel: Vec2,
+ /// Turning around the middle of the map, in radians per second: rocks in
+ /// a planet's orbit circle it rather than drifting through it.
+ pub circling: f64,
+ pub size: Size,
+ pub ore: Option<&'static Ore>,
+ /// Seconds of laser fire left before it breaks.
+ pub integrity: f64,
+}
+
+impl Rock {
+ pub fn new(pos: Vec2, vel: Vec2, circling: f64, size: Size, ore: Option<&'static Ore>) -> Rock {
+ Rock { pos, vel, circling, size, ore, integrity: size.toughness() }
+ }
+
+ pub fn velocity(&self) -> Vec2 {
+ self.vel + Vec2::new(-self.pos.y, self.pos.x) * self.circling
+ }
+
+ pub fn step(&mut self, dt: f64, extent: f64) {
+ self.pos = self.pos.rotated(self.circling * dt) + self.vel * dt;
+ // Drifters that leave one side come back in the other.
+ let wrap = |v: f64| (v + extent).rem_euclid(2.0 * extent) - extent;
+ self.pos = Vec2::new(wrap(self.pos.x), wrap(self.pos.y));
+ }
+}
+
+/// The planet or moon in the middle of the map. It sits at the origin.
+pub struct Body {
+ pub name: String,
+ pub kind: &'static BodyKind,
+ pub radius: f64,
+ /// Whether anyone lives there to trade with.
+ pub settled: bool,
+}
+
+/// The station in the middle of the map. It sits at the origin.
+pub struct Dock {
+ pub name: String,
+ pub culture: Culture,
+}
+
+/// How close to the middle of a station you must be to dock.
+pub const DOCK_RADIUS: f64 = 5.0;
+
+/// Something far off in the sky: a moon seen from its planet, or the
+/// planet behind a moon. You can't fly to it here; it's scenery that barely
+/// moves as you do.
+pub struct Backdrop {
+ pub name: String,
+ pub kind: &'static BodyKind,
+ /// Where it hangs, relative to the middle of the screen when you're at
+ /// the middle of the map.
+ pub pos: Vec2,
+ pub radius: f64,
+}
+
+/// How much backdrops move as you fly: a tenth as fast as the map.
+pub const BACKDROP_PARALLAX: f64 = 0.1;
+
+pub struct LocalWreck {
+ pub wreck: Wreck,
+ pub pos: Vec2,
+ /// Which way its bow points, for drawing.
+ pub heading: f64,
+}
+
+pub struct Locale {
+ pub place: usize,
+ pub name: String,
+ /// Where you are, as you'd say it: "in orbit around Qeth, a desert world".
+ pub what: String,
+ /// Half the width of the map. Past this the edge pushes you back, and
+ /// drifting rocks wrap round.
+ pub extent: f64,
+ /// Unit vector towards the star, on the map (y down).
+ pub sun: Vec2,
+ pub body: Option<Body>,
+ pub dock: Option<Dock>,
+ pub backdrops: Vec<Backdrop>,
+ pub rocks: Vec<Rock>,
+ pub wrecks: Vec<LocalWreck>,
+ /// Where you come in.
+ pub arrival: Vec2,
+}
+
+impl Locale {
+ /// What you'd crash the laser into, or land on: the body or the station,
+ /// as a circle at the origin.
+ pub fn solid_radius(&self) -> Option<f64> {
+ self.body.as_ref().map(|b| b.radius).or(self.dock.as_ref().map(|_| DOCK_RADIUS - 2.0))
+ }
+}
+
+/// From system coordinates (y up) to map coordinates (y down).
+fn to_map(v: Vec2) -> Vec2 {
+ Vec2::new(v.x, -v.y)
+}
+
+/// The map of `place` on `day`, having set out for it on `departed`,
+/// without the wrecks you've `stripped`.
+pub fn generate(
+ system: &System,
+ place: usize,
+ day: f64,
+ departed: Option<f64>,
+ stripped: &HashSet<u64>,
+) -> Locale {
+ let layout_seed = seed::derive(system.seed, "place", place as u64);
+ let mut rng = seed::rng(layout_seed);
+ let mut rocks_rng = seed::rng(seed::derive(layout_seed, "rocks", day.floor() as u64));
+
+ let here = system.position(place, day);
+ let sun = to_map(-here).normalised();
+ let name = system.places[place].name.clone();
+
+ let mut body = None;
+ let mut dock = None;
+ let mut backdrops = Vec::new();
+ let mut rocks = Vec::new();
+ let (what, extent);
+ match system.places[place].kind {
+ PlaceKind::Planet(p) => {
+ let planet = &system.planets[p];
+ what = format!("in orbit around {}, {}", planet.name, names::a(planet.kind.name));
+ let radius = planet.kind.size;
+ extent = 90.0 + radius * 2.0;
+ body = Some(Body {
+ name: planet.name.clone(),
+ kind: planet.kind,
+ radius,
+ settled: planet.people.is_some(),
+ });
+ for (m, moon) in planet.moons.iter().enumerate() {
+ let toward = to_map(moon.orbit.position(day)).normalised();
+ backdrops.push(Backdrop {
+ name: moon.name.clone(),
+ kind: moon.kind,
+ pos: toward * (40.0 + 12.0 * m as f64),
+ radius: moon.kind.size * 0.25,
+ });
+ }
+ let count = if planet.kind.giant {
+ rocks_rng.random_range(14..=24)
+ } else {
+ rocks_rng.random_range(2..=8)
+ };
+ ring(&mut rocks, &mut rocks_rng, count, radius + 8.0, radius + 30.0, 0.3);
+ }
+ PlaceKind::Moon(p, m) => {
+ let planet = &system.planets[p];
+ let moon = &planet.moons[m];
+ what = format!("in orbit around {}, a moon of {}", moon.name, planet.name);
+ let radius = moon.kind.size * 0.6;
+ extent = 80.0;
+ body = Some(Body {
+ name: moon.name.clone(),
+ kind: moon.kind,
+ radius,
+ settled: moon.people.is_some(),
+ });
+ backdrops.push(Backdrop {
+ name: planet.name.clone(),
+ kind: planet.kind,
+ pos: to_map(-moon.orbit.position(day)).normalised() * (36.0 + planet.kind.size),
+ radius: planet.kind.size * 1.1,
+ });
+ let count = rocks_rng.random_range(1..=5);
+ ring(&mut rocks, &mut rocks_rng, count, radius + 6.0, radius + 22.0, 0.3);
+ }
+ PlaceKind::Belt => {
+ let belt = &system.belt;
+ what = format!("among the rocks of {}", belt.name);
+ // A denser field spreads wider too, so it's thicker without
+ // being packed solid.
+ extent = 140.0 * belt.density.sqrt().max(1.0);
+ // The whole field drifts one way, each rock a little differently.
+ let flow = Vec2::from_heading(rng.random_range(0.0..TAU)) * rng.random_range(0.6..1.6);
+ let many = |n: f64| (n * belt.density).round() as i32;
+ for _ in 0..rocks_rng.random_range(many(70.0)..=many(100.0)) {
+ let pos = loop {
+ let pos = random_point(&mut rocks_rng, extent);
+ if pos.length() > 14.0 {
+ break pos;
+ }
+ };
+ let wobble = Vec2::from_heading(rocks_rng.random_range(0.0..TAU)) * 0.4;
+ let (size, ore) = random_rock(&mut rocks_rng, belt.richness);
+ rocks.push(Rock::new(pos, flow + wobble, 0.0, size, ore));
+ }
+ }
+ PlaceKind::Station(s) => {
+ let station = &system.stations[s];
+ let planet = &system.planets[station.planet];
+ let point = if station.leading { "L4" } else { "L5" };
+ what = format!("by {}, at {}'s {point} point", station.name, planet.name);
+ extent = 90.0;
+ dock = Some(Dock { name: station.name.clone(), culture: station.culture });
+ let toward_planet = planet.orbit.position(day) - here;
+ backdrops.push(Backdrop {
+ name: planet.name.clone(),
+ kind: planet.kind,
+ pos: to_map(toward_planet).normalised() * 40.0,
+ radius: planet.kind.size * 0.15,
+ });
+ // Trojans: a loose swarm, sharing the point with the station.
+ for _ in 0..rocks_rng.random_range(25..=40) {
+ let pos = loop {
+ let pos = random_point(&mut rocks_rng, extent);
+ if pos.length() > 18.0 {
+ break pos;
+ }
+ };
+ let vel = Vec2::from_heading(rocks_rng.random_range(0.0..TAU)) * 0.3;
+ let (size, ore) = random_rock(&mut rocks_rng, 0.45);
+ rocks.push(Rock::new(pos, vel, 0.0, size, ore));
+ }
+ }
+ }
+
+ let clear =
+ body.as_ref().map_or(0.0, |b| b.radius) + dock.as_ref().map_or(0.0, |_| DOCK_RADIUS);
+ let arrival = Vec2::new(0.0, (clear + 16.0).min(extent * 0.8));
+
+ let wrecks = system
+ .wrecks_at(place, day, departed, stripped)
+ .into_iter()
+ .map(|wreck| {
+ let mut rng = seed::rng(seed::derive(wreck.seed, "drift", place as u64));
+ let pos = loop {
+ let pos = random_point(&mut rng, extent * 0.7);
+ if pos.length() > clear + 12.0 && pos.distance(arrival) > 10.0 {
+ break pos;
+ }
+ };
+ LocalWreck { wreck, pos, heading: rng.random_range(0.0..TAU) }
+ })
+ .collect();
+
+ Locale { place, name, what, extent, sun, body, dock, backdrops, rocks, wrecks, arrival }
+}
+
+/// Rocks circling the middle of the map between two radii, all the same way.
+fn ring(rocks: &mut Vec<Rock>, rng: &mut Rng, count: usize, inner: f64, outer: f64, ore: f64) {
+ let way = if rng.random_bool(0.5) { 1.0 } else { -1.0 };
+ for _ in 0..count {
+ let r = rng.random_range(inner..outer);
+ let pos = Vec2::from_heading(rng.random_range(0.0..TAU)) * r;
+ // Further out goes round more slowly, as in a real orbit.
+ let circling = way * 4.0 / r.powf(1.5) * rng.random_range(0.9..1.1);
+ let (size, ore) = random_rock(rng, ore);
+ rocks.push(Rock::new(pos, Vec2::ZERO, circling, size, ore));
+ }
+}
+
+fn random_rock(rng: &mut Rng, ore_chance: f64) -> (Size, Option<&'static Ore>) {
+ let size = *[Size::Small, Size::Medium, Size::Medium, Size::Large].choose(rng).unwrap();
+ let ore = rng.random_bool(ore_chance).then(|| ORES.choose_weighted(rng, |o| o.weight).unwrap());
+ (size, ore)
+}
+
+fn random_point(rng: &mut Rng, extent: f64) -> Vec2 {
+ Vec2::new(rng.random_range(-extent..extent), rng.random_range(-extent..extent))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::space::system;
+
+ #[test]
+ fn every_place_makes_a_map() {
+ for seed in 0..40 {
+ let s = system::generate(seed);
+ for place in 0..s.places.len() {
+ let l = generate(&s, place, 12.5, None, &HashSet::new());
+ assert!((l.sun.length() - 1.0).abs() < 1e-9);
+ assert!(l.arrival.x.abs() < l.extent && l.arrival.y.abs() < l.extent);
+ let solid = l.solid_radius().unwrap_or(0.0);
+ assert!(l.arrival.length() > solid + 5.0, "you arrive clear of what's there");
+ for r in &l.rocks {
+ assert!(r.pos.length() > solid, "{}: a rock inside the middle", l.name);
+ }
+ let here = s.wrecks_on(12.5).iter().filter(|w| w.place == place).count();
+ assert_eq!(l.wrecks.len(), here);
+ }
+ }
+ }
+
+ #[test]
+ fn the_layout_stays_but_the_rocks_come_back() {
+ let s = system::generate(2);
+ let belt = s.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap();
+ let (a, b, c) = {
+ let none = HashSet::new();
+ (
+ generate(&s, belt, 3.2, None, &none),
+ generate(&s, belt, 3.7, None, &none),
+ generate(&s, belt, 9.0, None, &none),
+ )
+ };
+ assert_eq!(a.rocks.len(), b.rocks.len(), "same day, same rocks");
+ assert_eq!(a.rocks[0].pos, b.rocks[0].pos);
+ assert_ne!(a.rocks[0].pos, c.rocks[0].pos, "another day, new rocks");
+ }
+
+ #[test]
+ fn circling_rocks_keep_their_distance() {
+ let mut rock = Rock::new(Vec2::new(30.0, 0.0), Vec2::ZERO, 0.05, Size::Small, None);
+ for _ in 0..1000 {
+ rock.step(0.1, 100.0);
+ }
+ assert!((rock.pos.length() - 30.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn drifting_rocks_wrap_round() {
+ let mut rock = Rock::new(Vec2::new(99.0, 0.0), Vec2::new(5.0, 0.0), 0.0, Size::Small, None);
+ rock.step(1.0, 100.0);
+ assert!((rock.pos.x + 96.0).abs() < 1e-9);
+ }
+}
diff --git a/src/space/mod.rs b/src/space/mod.rs
new file mode 100644
index 0000000..74b092e
--- /dev/null
+++ b/src/space/mod.rs
@@ -0,0 +1,154 @@
+//! Space: star systems with proper orbits, the places in them you can fly
+//! around, and the rules of flight.
+//!
+//! There are two scales. A system is measured in AU and days: planets follow
+//! Kepler orbits around the star, moons around their planets, and the
+//! calendar moves them on. You never fly at that scale. Instead you travel
+//! between places (a planet's orbit, a moon, a belt, a station), and each
+//! place is a small local map you fly around in, measured in local units:
+//! one unit is one terminal cell across and half a cell down (cells are about
+//! twice as tall as they are wide), so circles come out round on screen.
+//! Local maps use screen-style coordinates, with y pointing down.
+//!
+//! Above both is the galaxy: star systems in light-years, joined by lanes
+//! you jump along (see `galaxy`).
+
+pub mod flight;
+pub mod galaxy;
+pub mod locale;
+pub mod orbit;
+pub mod survey;
+pub mod system;
+
+use std::f64::consts::{PI, TAU};
+use std::ops::{Add, AddAssign, Mul, Neg, Sub, SubAssign};
+
+use crate::seed;
+
+/// The star system a universe first started everyone in. It's one of the
+/// four peoples' seats in its galaxy (see `galaxy`).
+pub fn home_system(universe: u64) -> system::System {
+ system::generate(seed::derive(universe, "system", 0))
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct Vec2 {
+ pub x: f64,
+ pub y: f64,
+}
+
+impl Vec2 {
+ pub const ZERO: Vec2 = Vec2::new(0.0, 0.0);
+
+ pub const fn new(x: f64, y: f64) -> Vec2 {
+ Vec2 { x, y }
+ }
+
+ /// On a local map, the unit vector pointing along `heading`: 0 is up the
+ /// screen, and headings increase clockwise.
+ pub fn from_heading(heading: f64) -> Vec2 {
+ Vec2::new(heading.sin(), -heading.cos())
+ }
+
+ /// The heading this vector points along, the inverse of `from_heading`.
+ pub fn heading(self) -> f64 {
+ self.x.atan2(-self.y).rem_euclid(TAU)
+ }
+
+ pub fn rotated(self, angle: f64) -> Vec2 {
+ let (s, c) = angle.sin_cos();
+ Vec2::new(self.x * c - self.y * s, self.x * s + self.y * c)
+ }
+
+ pub fn length(self) -> f64 {
+ self.x.hypot(self.y)
+ }
+
+ pub fn distance(self, other: Vec2) -> f64 {
+ (self - other).length()
+ }
+
+ pub fn dot(self, other: Vec2) -> f64 {
+ self.x * other.x + self.y * other.y
+ }
+
+ /// This vector at length 1, or zero if it has no length.
+ pub fn normalised(self) -> Vec2 {
+ let len = self.length();
+ if len > 0.0 { self * (1.0 / len) } else { Vec2::ZERO }
+ }
+}
+
+impl Add for Vec2 {
+ type Output = Vec2;
+ fn add(self, o: Vec2) -> Vec2 {
+ Vec2::new(self.x + o.x, self.y + o.y)
+ }
+}
+
+impl Sub for Vec2 {
+ type Output = Vec2;
+ fn sub(self, o: Vec2) -> Vec2 {
+ Vec2::new(self.x - o.x, self.y - o.y)
+ }
+}
+
+impl Mul<f64> for Vec2 {
+ type Output = Vec2;
+ fn mul(self, k: f64) -> Vec2 {
+ Vec2::new(self.x * k, self.y * k)
+ }
+}
+
+impl Neg for Vec2 {
+ type Output = Vec2;
+ fn neg(self) -> Vec2 {
+ Vec2::new(-self.x, -self.y)
+ }
+}
+
+impl AddAssign for Vec2 {
+ fn add_assign(&mut self, o: Vec2) {
+ *self = *self + o;
+ }
+}
+
+impl SubAssign for Vec2 {
+ fn sub_assign(&mut self, o: Vec2) {
+ *self = *self - o;
+ }
+}
+
+/// The signed angle to turn through to get from `from` to `to`, the short
+/// way round: between -π and π.
+pub fn turn_between(from: f64, to: f64) -> f64 {
+ (to - from + PI).rem_euclid(TAU) - PI
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn headings_round_trip() {
+ for i in 0..16 {
+ let h = TAU * f64::from(i) / 16.0;
+ assert!((Vec2::from_heading(h).heading() - h).abs() < 1e-9);
+ }
+ let up = Vec2::from_heading(0.0);
+ assert!(up.x.abs() < 1e-9 && (up.y + 1.0).abs() < 1e-9, "heading 0 points up the screen");
+ assert!((Vec2::from_heading(TAU / 4.0).x - 1.0).abs() < 1e-9, "a quarter turn is right");
+ }
+
+ #[test]
+ fn turning_takes_the_short_way() {
+ assert!((turn_between(0.1, TAU - 0.1) + 0.2).abs() < 1e-9);
+ assert!((turn_between(TAU - 0.1, 0.1) - 0.2).abs() < 1e-9);
+ }
+
+ #[test]
+ fn rotation_is_anticlockwise() {
+ let v = Vec2::new(1.0, 0.0).rotated(PI / 2.0);
+ assert!(v.x.abs() < 1e-9 && (v.y - 1.0).abs() < 1e-9);
+ }
+}
diff --git a/src/space/orbit.rs b/src/space/orbit.rs
new file mode 100644
index 0000000..0bcea5e
--- /dev/null
+++ b/src/space/orbit.rs
@@ -0,0 +1,164 @@
+//! Kepler orbits, and how long trips between them take.
+//!
+//! Distances are in AU, times in days, masses in suns. Orbits lie flat in
+//! the plane of the system (no inclination) and run anticlockwise.
+
+use std::f64::consts::TAU;
+
+use super::Vec2;
+
+/// Days in a year for a body 1 AU from a star of one solar mass.
+const YEAR: f64 = 365.25;
+/// An Earth mass, in suns.
+pub const EARTH_MASS: f64 = 3.003e-6;
+
+#[derive(Clone, Copy, Debug)]
+pub struct Orbit {
+ /// Semi-major axis.
+ pub a: f64,
+ /// Eccentricity: 0 is a circle.
+ pub e: f64,
+ /// Angle from the x axis to the closest point of the orbit.
+ pub periapsis: f64,
+ /// Where along the orbit the body is at day 0, as a mean anomaly.
+ pub mean_at_epoch: f64,
+ /// Days per revolution.
+ pub period: f64,
+}
+
+impl Orbit {
+ /// An orbit around a body of `parent_mass` suns, with its period from
+ /// Kepler's third law.
+ pub fn new(a: f64, e: f64, periapsis: f64, mean_at_epoch: f64, parent_mass: f64) -> Orbit {
+ let period = YEAR * (a.powi(3) / parent_mass).sqrt();
+ Orbit { a, e, periapsis, mean_at_epoch, period }
+ }
+
+ /// Position relative to the parent on `day`.
+ pub fn position(&self, day: f64) -> Vec2 {
+ let mean = self.mean_at_epoch + TAU * day / self.period;
+ self.at_eccentric_anomaly(eccentric_anomaly(mean, self.e))
+ }
+
+ /// `n` points evenly spaced around the ellipse, for drawing it.
+ pub fn outline(&self, n: usize) -> impl Iterator<Item = Vec2> + '_ {
+ (0..n).map(move |i| self.at_eccentric_anomaly(TAU * i as f64 / n as f64))
+ }
+
+ /// Closest and farthest distances from the parent.
+ pub fn periapsis_distance(&self) -> f64 {
+ self.a * (1.0 - self.e)
+ }
+
+ pub fn apoapsis_distance(&self) -> f64 {
+ self.a * (1.0 + self.e)
+ }
+
+ fn at_eccentric_anomaly(&self, ea: f64) -> Vec2 {
+ let x = self.a * (ea.cos() - self.e);
+ let y = self.a * (1.0 - self.e * self.e).sqrt() * ea.sin();
+ Vec2::new(x, y).rotated(self.periapsis)
+ }
+}
+
+/// Solves Kepler's equation, M = E - e sin E, for E by Newton's method.
+fn eccentric_anomaly(mean: f64, e: f64) -> f64 {
+ let m = mean.rem_euclid(TAU);
+ let mut ea = if e > 0.8 { std::f64::consts::PI } else { m };
+ for _ in 0..12 {
+ let step = (ea - e * ea.sin() - m) / (1.0 - e * ea.cos());
+ ea -= step;
+ if step.abs() < 1e-12 {
+ break;
+ }
+ }
+ ea
+}
+
+/// Days a trip of 1 AU takes. Trips are skewed well away from realism, so
+/// that a far planet costs only a few days more than a near one: days grow
+/// with the cube root of the distance. A moon is hours away, most planets
+/// two to five days, and the far edge of the widest systems about ten.
+pub const AU_TRIP: f64 = 2.25;
+
+/// The shortest trip, however close the destination: undocking, getting
+/// clear and settling into the new place.
+pub const MIN_TRIP: f64 = 0.25;
+
+pub fn trip_days(distance: f64) -> f64 {
+ (AU_TRIP * distance.cbrt()).max(MIN_TRIP)
+}
+
+/// How many days a trip from `from` (where you are now, on `day`) to a
+/// destination that moves along `destination(day)` takes. Aims for where
+/// the destination will be on arrival, not where it is now.
+pub fn plan_trip(from: Vec2, day: f64, destination: impl Fn(f64) -> Vec2) -> f64 {
+ let mut days = trip_days(from.distance(destination(day)));
+ for _ in 0..8 {
+ days = trip_days(from.distance(destination(day + days)));
+ }
+ days
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn keplers_third_law() {
+ let earthlike = Orbit::new(1.0, 0.0, 0.0, 0.0, 1.0);
+ assert!((earthlike.period - 365.25).abs() < 1e-9);
+ let far = Orbit::new(4.0, 0.0, 0.0, 0.0, 1.0);
+ assert!(
+ (far.period - 8.0 * 365.25).abs() < 1e-9,
+ "four times out takes eight times as long"
+ );
+ }
+
+ #[test]
+ fn orbits_come_round_again() {
+ let o = Orbit::new(2.3, 0.2, 1.0, 0.4, 0.8);
+ let (p, q) = (o.position(17.0), o.position(17.0 + o.period));
+ assert!(p.distance(q) < 1e-9);
+ }
+
+ #[test]
+ fn ellipses_swing_between_periapsis_and_apoapsis() {
+ let o = Orbit::new(2.0, 0.25, 0.7, 0.0, 1.0);
+ let at_start = o.position(0.0).length();
+ assert!((at_start - o.periapsis_distance()).abs() < 1e-9, "mean anomaly 0 is periapsis");
+ let half = o.position(o.period / 2.0).length();
+ assert!((half - o.apoapsis_distance()).abs() < 1e-9);
+ let p = o.position(0.0);
+ let angle = p.y.atan2(p.x);
+ assert!((angle - 0.7).abs() < 1e-9, "periapsis points where it says");
+ }
+
+ #[test]
+ fn keplers_second_law_bodies_hurry_near_periapsis() {
+ let o = Orbit::new(1.0, 0.3, 0.0, 0.0, 1.0);
+ let near = o.position(0.0).distance(o.position(1.0));
+ let far = o.position(o.period / 2.0).distance(o.position(o.period / 2.0 + 1.0));
+ assert!(near > far * 1.5);
+ }
+
+ #[test]
+ fn trips_aim_ahead_of_a_moving_target() {
+ let target = Orbit::new(1.0, 0.0, 0.0, 0.0, 1.0);
+ let from = Vec2::new(5.0, 0.0);
+ let days = plan_trip(from, 0.0, |d| target.position(d));
+ let arrival = target.position(days);
+ assert!((trip_days(from.distance(arrival)) - days).abs() < 1e-6);
+ }
+
+ #[test]
+ fn trips_take_days_not_weeks() {
+ assert!(trip_days(0.003) < 0.5, "a moon is hours away");
+ assert!((2.0..3.0).contains(&trip_days(1.0)));
+ assert!(trip_days(10.0) < 5.0);
+ assert!(trip_days(90.0) < 11.0, "even the far edge of a wide system");
+ assert_eq!(trip_days(1e-6), MIN_TRIP);
+ // Far places cost more, but not much more.
+ assert!((trip_days(27.0) / trip_days(1.0) - 3.0).abs() < 1e-9);
+ }
+}
diff --git a/src/space/survey.rs b/src/space/survey.rs
new file mode 100644
index 0000000..e3c91fe
--- /dev/null
+++ b/src/space/survey.rs
@@ -0,0 +1,365 @@
+//! What a ship's survey says about a place: how hot it is and how that
+//! swings over its year, how long its days are, how hard it pulls, what its
+//! air is like, what hangs in its sky, and how many people live there.
+//! None of it changes how the game plays. It's there so each place feels
+//! like somewhere.
+//!
+//! Temperatures are worked out, not made up: the starlight a body gets at
+//! its distance from the star (which changes along an eccentric orbit),
+//! less what it reflects, plus what its air traps or its insides give off.
+//! Moons share their planet's distance. Everything else is drawn from a
+//! hash of the place, so it's the same every visit and system generation
+//! (and every universe already made) is left alone.
+
+use std::f64::consts::TAU;
+
+use super::orbit::{EARTH_MASS, Orbit};
+use super::system::{Climate, Landing, PlaceKind, StarKind, System, SystemKind};
+use crate::names::Culture;
+use crate::scenes::sky::hash01;
+use crate::seed;
+
+/// How warm a bare, black, quickly turning rock gets 1 AU from a star of
+/// one sun, in kelvin.
+const WARMTH_AT_1AU: f64 = 278.6;
+/// Water freezes at this many kelvin.
+pub const FREEZING: f64 = 273.15;
+/// How wide the Sun looks from 1 AU, in degrees.
+const SUN_AT_1AU: f64 = 0.533;
+const EARTH_RADIUS_KM: f64 = 6371.0;
+const AU_KM: f64 = 149.6e6;
+/// Rocks in the belt: dark, and no air to keep them warm.
+const BELT_CLIMATE: Climate = Climate { albedo: 0.1, warmth: 0.0, air: &[], breathable: false };
+
+pub struct Survey {
+ /// At the surface, or a giant's cloud tops. None aboard a station,
+ /// where it's whatever they set it to.
+ pub temperature: Option<Temperature>,
+ /// How long a day lasts, or how fast a station spins.
+ pub day: Option<String>,
+ /// What its tilt does to its year, if it has days to speak of.
+ pub seasons: Option<&'static str>,
+ /// In g: at the surface, at a giant's cloud tops, or from a station's spin.
+ pub gravity: Option<f64>,
+ /// What the air is like, and whether you can breathe it. None if
+ /// there isn't any.
+ pub air: Option<(&'static str, bool)>,
+ /// What you'd see overhead.
+ pub sky: String,
+ /// How many live there, if anyone does.
+ pub population: Option<u64>,
+}
+
+/// In kelvin.
+#[derive(Clone, Copy, Debug)]
+pub struct Temperature {
+ pub now: f64,
+ /// At the far end of its orbit.
+ pub coldest: f64,
+ /// At the near end.
+ pub hottest: f64,
+}
+
+impl Temperature {
+ fn along(orbit: &Orbit, day: f64, star: &StarKind, climate: &Climate) -> Temperature {
+ let at = |distance: f64| {
+ WARMTH_AT_1AU * (star.luminosity * (1.0 - climate.albedo)).powf(0.25) / distance.sqrt()
+ + climate.warmth
+ };
+ Temperature {
+ now: at(orbit.position(day).length()),
+ coldest: at(orbit.apoapsis_distance()),
+ hottest: at(orbit.periapsis_distance()),
+ }
+ }
+
+ /// One word for how it feels, going by the temperature now.
+ pub fn word(self) -> &'static str {
+ match self.now - FREEZING {
+ c if c < -150.0 => "deep-frozen",
+ c if c < -60.0 => "frozen",
+ c if c < -20.0 => "bitter",
+ c if c < 5.0 => "cold",
+ c if c < 15.0 => "cool",
+ c if c < 28.0 => "temperate",
+ c if c < 45.0 => "hot",
+ c if c < 120.0 => "scorching",
+ c if c < 400.0 => "searing",
+ _ => "molten",
+ }
+ }
+}
+
+/// Kelvin to whole degrees Celsius, for showing.
+pub fn celsius(kelvin: f64) -> i64 {
+ (kelvin - FREEZING).round() as i64
+}
+
+pub fn survey(s: &System, place: usize, day: f64) -> Survey {
+ let key = seed::derive(s.seed, "survey", place as u64);
+ let h = |i: i64| hash01(key, i, 0);
+ let star = s.star;
+ let landing = s.landing(place);
+ match s.places[place].kind {
+ PlaceKind::Planet(p) => {
+ let planet = &s.planets[p];
+ let kind = planet.kind;
+ let temperature = Temperature::along(&planet.orbit, day, star, &kind.climate);
+ // Close to a small star, tides stop a world turning.
+ let locked = !kind.giant && planet.orbit.a < 0.4 * star.mass;
+ let (day_length, seasons) = if locked {
+ ("locked: one face in endless day".to_string(), None)
+ } else {
+ let hours = if kind.giant {
+ lerp(9.0, 18.0, h(1))
+ } else if h(2) < 0.08 {
+ 24.0 * log_lerp(5.0, 120.0, h(1))
+ } else {
+ log_lerp(10.0, 80.0, h(1))
+ };
+ (duration(hours), Some(seasons(h(3), h(4))))
+ };
+ let earths = planet.mass / EARTH_MASS;
+ let radius = radius(earths, kind.giant);
+ let moons = match planet.moons.len() {
+ 0 => String::new(),
+ 1 => " and a moon".to_string(),
+ n => format!(" and {n} moons"),
+ };
+ Survey {
+ temperature: Some(temperature),
+ day: Some(day_length),
+ seasons,
+ gravity: Some(earths / (radius * radius)),
+ air: air(&kind.climate, temperature, h(5)),
+ sky: format!("{}{moons}", sun(star, planet.orbit.position(day).length())),
+ population: population(landing, s.kind, p == s.home, false, h(6)),
+ }
+ }
+ PlaceKind::Moon(p, m) => {
+ let planet = &s.planets[p];
+ let moon = &planet.moons[m];
+ let temperature = Temperature::along(&planet.orbit, day, star, &moon.kind.climate);
+ let earths = log_lerp(0.004, 0.12, h(1));
+ // How wide its planet looks, from down on the surface.
+ let planet_km = radius(planet.mass / EARTH_MASS, planet.kind.giant) * EARTH_RADIUS_KM;
+ let across = 2.0 * (planet_km / (moon.orbit.a * AU_KM)).atan().to_degrees();
+ let size = if across < 1.0 { format!("{across:.1}") } else { format!("{across:.0}") };
+ let looms = if across >= 3.0 { "looms" } else { "hangs" };
+ Survey {
+ temperature: Some(temperature),
+ day: Some(format!(
+ "{}, always facing {}",
+ duration(24.0 * moon.orbit.period),
+ planet.name
+ )),
+ seasons: None,
+ gravity: Some(earths.powf(0.46)),
+ air: air(&moon.kind.climate, temperature, h(5)),
+ sky: format!(
+ "{} {looms} {size}° wide; {}",
+ planet.name,
+ sun(star, planet.orbit.position(day).length())
+ ),
+ population: population(landing, s.kind, false, true, h(6)),
+ }
+ }
+ PlaceKind::Station(k) => {
+ let station = &s.stations[k];
+ let planet = &s.planets[station.planet];
+ // Spun for gravity: the bigger the wheel, the slower it turns.
+ let g = lerp(0.35, 1.0, h(1));
+ let wheel = lerp(150.0, 900.0, h(2));
+ let seconds = TAU * (wheel / (g * 9.81)).sqrt();
+ Survey {
+ temperature: None,
+ day: Some(format!("turns once every {seconds:.0} seconds")),
+ seasons: None,
+ gravity: Some(g),
+ air: Some((station_air(station.culture), true)),
+ sky: format!(
+ "{}, and {} a bright star",
+ sun(star, planet.orbit.position(day).length()),
+ planet.name
+ ),
+ population: population(landing, s.kind, false, false, h(6)),
+ }
+ }
+ PlaceKind::Belt => {
+ let region = &s.belt.region;
+ Survey {
+ temperature: Some(Temperature::along(region, day, star, &BELT_CLIMATE)),
+ day: None,
+ seasons: None,
+ gravity: None,
+ air: None,
+ sky: format!("{}, and rocks tumbling all round", sun(star, region.a)),
+ population: None,
+ }
+ }
+ }
+}
+
+/// In Earth radii, for a body of `earths` Earth masses: rocky worlds grow
+/// slowly with mass, and giants stop growing at about Jupiter's size.
+fn radius(earths: f64, giant: bool) -> f64 {
+ if giant { earths.powf(0.48).min(11.3) } else { earths.powf(0.27) }
+}
+
+fn air(climate: &Climate, t: Temperature, h: f64) -> Option<(&'static str, bool)> {
+ if climate.air.is_empty() {
+ return None;
+ }
+ let what = climate.air[(h * climate.air.len() as f64) as usize % climate.air.len()];
+ let bearable = (FREEZING - 40.0..=FREEZING + 55.0).contains(&t.now);
+ Some((what, climate.breathable && bearable))
+}
+
+fn station_air(culture: Culture) -> &'static str {
+ match culture {
+ Culture::Qaari => "warm and dry, scented with spice-tea",
+ Culture::Cant => "recycled; hot solder and incense",
+ Culture::Houses => "perfumed, and filtered twice over",
+ Culture::Drift => "stale, and patched like the hulls",
+ }
+}
+
+/// The star as it looks from `distance` AU.
+fn sun(star: &StarKind, distance: f64) -> String {
+ let colour = star.name.trim_end_matches(" dwarf").trim_end_matches(" star");
+ // Near enough for a star in the prime of its life.
+ let radius = star.mass.powf(0.8);
+ let degrees = SUN_AT_1AU * radius / distance;
+ let size = match degrees {
+ d if d < 0.12 => return format!("a {colour} sun, shrunk to a point"),
+ d if d < 0.35 => "small ",
+ d if d < 0.9 => "",
+ d if d < 2.5 => "broad ",
+ d if d < 6.0 => "vast ",
+ _ => "sky-filling ",
+ };
+ format!("a {size}{colour} sun")
+}
+
+/// How much its tilt changes its year. Most worlds lean a little; a few
+/// roll round their star on their side.
+fn seasons(h: f64, h2: f64) -> &'static str {
+ let tilt = if h < 0.05 { 80.0 + 20.0 * h2 } else { 40.0 * h2 * h2 };
+ match tilt {
+ t if t < 4.0 => "no seasons",
+ t if t < 15.0 => "mild seasons",
+ t if t < 30.0 => "strong seasons",
+ t if t < 60.0 => "harsh seasons",
+ _ => "spins on its side",
+ }
+}
+
+/// How many people live somewhere: billions on a seat's homeworld, far
+/// fewer out on the frontier or among the wrecks.
+fn population(landing: Landing, kind: SystemKind, main: bool, moon: bool, h: f64) -> Option<u64> {
+ let (low, high) = match landing {
+ Landing::Capital(_) if main => match kind {
+ SystemKind::Seat => (6e8, 4e9),
+ SystemKind::Settled => (4e7, 6e8),
+ SystemKind::Rubble => (3e6, 6e7),
+ _ => (2e5, 8e6),
+ },
+ Landing::Capital(_) if moon => (2e5, 2e7),
+ Landing::Capital(_) => (5e6, 3e8),
+ Landing::Aloft(_) => (1e6, 4e7),
+ Landing::Market(_) => (3e3, 8e4),
+ Landing::Wilds | Landing::Nowhere => return None,
+ };
+ // Away from the settled heart of things, everywhere is emptier.
+ let thin = matches!(kind, SystemKind::Frontier | SystemKind::Graveyard) && !main;
+ let (low, high) = if thin { (low * 0.1, high * 0.1) } else { (low, high) };
+ // To two figures: nobody counts heads more closely than that.
+ let n = log_lerp(low, high, h);
+ let unit = 10f64.powi(n.log10().floor() as i32 - 1);
+ Some(((n / unit).round() * unit) as u64)
+}
+
+/// "26 hours", "3.5 days", "41 days".
+pub fn duration(hours: f64) -> String {
+ let days = hours / 24.0;
+ if hours < 72.0 {
+ format!("{hours:.0} hours")
+ } else if days < 10.0 {
+ format!("{days:.1} days")
+ } else {
+ format!("{days:.0} days")
+ }
+}
+
+fn lerp(low: f64, high: f64, k: f64) -> f64 {
+ low + (high - low) * k
+}
+
+/// From `low` to `high`, evenly in orders of magnitude.
+fn log_lerp(low: f64, high: f64, k: f64) -> f64 {
+ (low.ln() + (high.ln() - low.ln()) * k).exp()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::space::system::generate;
+
+ #[test]
+ fn surveys_are_the_same_every_time() {
+ let s = generate(4);
+ for place in 0..s.places.len() {
+ let (a, b) = (survey(&s, place, 30.0), survey(&s, place, 30.0));
+ assert_eq!((a.day, a.sky, a.population), (b.day, b.sky, b.population));
+ }
+ }
+
+ #[test]
+ fn temperatures_swing_between_the_ends_of_the_orbit() {
+ for seed in 0..60 {
+ let s = generate(seed);
+ for place in 0..s.places.len() {
+ for day in [0.0, 100.0, 1234.5] {
+ let Some(t) = survey(&s, place, day).temperature else { continue };
+ assert!(t.coldest <= t.now + 1e-9 && t.now <= t.hottest + 1e-9);
+ assert!(t.coldest > 0.0, "seed {seed}: below absolute zero");
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn a_wet_world_in_the_middle_of_the_habitable_zone_is_mild() {
+ let star = &crate::space::system::STARS[0];
+ let hz = star.luminosity.sqrt();
+ let orbit = Orbit::new(hz, 0.0, 0.0, 0.0, star.mass);
+ let ocean = Climate { albedo: 0.3, warmth: 33.0, air: &["wet"], breathable: true };
+ let t = Temperature::along(&orbit, 0.0, star, &ocean);
+ assert_eq!(celsius(t.now), 15, "about Earth's average");
+ assert_eq!(t.word(), "cool");
+ assert_eq!(air(&ocean, t, 0.0), Some(("wet", true)));
+ }
+
+ #[test]
+ fn only_settled_places_have_people() {
+ for seed in 0..40 {
+ let s = generate(seed);
+ for place in 0..s.places.len() {
+ let people = survey(&s, place, 0.0).population;
+ assert_eq!(
+ people.is_some(),
+ !matches!(s.landing(place), Landing::Wilds | Landing::Nowhere)
+ );
+ }
+ let home = survey(&s, s.home_place(), 0.0).population.unwrap();
+ assert!(home >= 600_000_000, "seed {seed}: a homeworld of {home}");
+ }
+ }
+
+ #[test]
+ fn durations_read_naturally() {
+ assert_eq!(duration(26.2), "26 hours");
+ assert_eq!(duration(84.0), "3.5 days");
+ assert_eq!(duration(24.0 * 41.2), "41 days");
+ }
+}
diff --git a/src/space/system.rs b/src/space/system.rs
new file mode 100644
index 0000000..7a3742a
--- /dev/null
+++ b/src/space/system.rs
@@ -0,0 +1,1215 @@
+//! Star systems: a star, planets on Kepler orbits with moons of their own,
+//! an asteroid belt, stations at Lagrange points, and wrecks that come and
+//! go with the calendar.
+//!
+//! Planets are laid out outward from the star, each orbit 1.5-2 times the
+//! last (roughly how real systems space out). What a planet is depends on
+//! where it sits: scorched rock close in, temperate worlds around the
+//! habitable zone, ice beyond, and giants past the frost line. The belt
+//! takes the first wide gap past the frost line.
+//!
+//! Everything you can travel to is a `Place`, listed outward from the star.
+//!
+//! Systems come in kinds (see `SystemKind`): a people's seat, ordinary
+//! settled systems, thin frontiers, desolate ones no one lives in, rubble
+//! fields with a great belt, and graveyards of old wars. The galaxy decides
+//! which kind each system is, and who holds it, and hands that over as a
+//! `Profile`. The system a universe first started you in is made without
+//! one, exactly as it always was, so old universes and saves keep it.
+
+use std::cell::RefCell;
+use std::collections::{BTreeMap, HashSet};
+use std::f64::consts::{PI, TAU};
+use std::ops::RangeInclusive;
+
+use rand::RngExt;
+use rand::seq::IndexedRandom;
+
+use super::Vec2;
+use super::orbit::{EARTH_MASS, Orbit};
+use crate::dungeon::crawl;
+use crate::names::Culture;
+use crate::seed::{self, Rng};
+
+pub type Rgb = (u8, u8, u8);
+
+pub struct StarKind {
+ pub name: &'static str,
+ pub rgb: Rgb,
+ /// In suns.
+ pub mass: f64,
+ /// In suns; sets where the habitable zone is.
+ pub luminosity: f64,
+ weight: u32,
+ /// How likely it is in a system no one lives in.
+ dead_weight: u32,
+}
+
+#[rustfmt::skip]
+pub const STARS: &[StarKind] = &[
+ StarKind { name: "yellow dwarf", rgb: (255, 226, 130), mass: 1.0, luminosity: 1.0, weight: 4, dead_weight: 1 },
+ StarKind { name: "orange dwarf", rgb: (255, 168, 90), mass: 0.75, luminosity: 0.35, weight: 3, dead_weight: 2 },
+ StarKind { name: "red dwarf", rgb: (255, 110, 80), mass: 0.4, luminosity: 0.04, weight: 2, dead_weight: 5 },
+ StarKind { name: "blue-white star", rgb: (175, 205, 255), mass: 1.9, luminosity: 9.0, weight: 1, dead_weight: 2 },
+];
+
+/// A star for a system of this kind: dim red dwarfs, mostly, where no one
+/// lives.
+pub fn random_star(kind: SystemKind, rng: &mut Rng) -> &'static StarKind {
+ if kind == SystemKind::Desolate {
+ STARS.choose_weighted(rng, |s| s.dead_weight).unwrap()
+ } else {
+ STARS.choose_weighted(rng, |s| s.weight).unwrap()
+ }
+}
+
+/// What sort of system it is, which shapes everything in it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum SystemKind {
+ /// The seat of a people: their homeworld and its great capital.
+ Seat,
+ /// An ordinary system: a capital, settled worlds, a station or two.
+ Settled,
+ /// Thinly settled: a small capital or an outpost station.
+ Frontier,
+ /// No one lives here at all, and nothing is for sale.
+ Desolate,
+ /// Few planets, and a wide belt thick with ore, worked by mining stations.
+ Rubble,
+ /// An old battlefield: wrecks everywhere, few people.
+ Graveyard,
+}
+
+impl SystemKind {
+ pub const ALL: [SystemKind; 6] = [
+ SystemKind::Seat,
+ SystemKind::Settled,
+ SystemKind::Frontier,
+ SystemKind::Desolate,
+ SystemKind::Rubble,
+ SystemKind::Graveyard,
+ ];
+
+ /// "a frontier system", for "a frontier system of the Qaari".
+ pub fn name(self) -> &'static str {
+ match self {
+ SystemKind::Seat => "the seat",
+ SystemKind::Settled => "a settled system",
+ SystemKind::Frontier => "a frontier system",
+ SystemKind::Desolate => "a desolate system",
+ SystemKind::Rubble => "a rubble field",
+ SystemKind::Graveyard => "a graveyard of ships",
+ }
+ }
+
+ /// What you'll find there, in a sentence.
+ pub fn blurb(self) -> &'static str {
+ match self {
+ SystemKind::Seat => "Their homeworld and its great capital, busy stations, fuel.",
+ SystemKind::Settled => "Settled worlds round a capital, a station or two, fuel.",
+ SystemKind::Frontier => "Thinly settled: a small capital or an outpost, and fuel.",
+ SystemKind::Desolate => "No one lives here. Nothing is for sale, fuel least of all.",
+ SystemKind::Rubble => "A wide belt thick with ore, worked by mining stations.",
+ SystemKind::Graveyard => "An old battlefield. Wrecks drift everywhere; few people.",
+ }
+ }
+
+ /// Whether anyone in a system of this kind sells fuel. Only desolate
+ /// systems have no one to.
+ pub fn sells_fuel(self) -> bool {
+ self != SystemKind::Desolate
+ }
+}
+
+/// What the galaxy decides about a system before it's made.
+pub struct Profile {
+ pub name: String,
+ /// Who holds it.
+ pub culture: Culture,
+ pub star: &'static StarKind,
+ pub kind: SystemKind,
+ /// The people over the border, for a system on one: they settle here
+ /// too, and may keep a station.
+ pub neighbour: Option<Culture>,
+}
+
+/// How a kind of system is laid out and settled.
+struct Rules {
+ planets: RangeInclusive<usize>,
+ /// How much wider than it starts the belt is, outer edge over inner.
+ belt_width: (f64, f64),
+ /// Scales every world's chance of being settled.
+ settle: f64,
+ /// Of settled worlds, the share the system's own people hold.
+ own_share: f64,
+ /// Who holds the rest; anyone at all, if no one in particular.
+ minority: Option<Culture>,
+ /// The chance the main world has a capital.
+ capital: f64,
+ /// No life: oceans and jungles give way to rock and ice.
+ dead: bool,
+ stations: Stations,
+ /// How many wrecks are calling at once, on average.
+ wrecks: f64,
+ /// Rocks in the belt, and the share with ore in them, against an
+ /// ordinary belt's.
+ density: f64,
+ richness: f64,
+}
+
+#[derive(Clone, Copy, PartialEq)]
+enum Stations {
+ /// One at the heaviest planet, and often one at the main world.
+ Usual,
+ /// One at the heaviest planet and one at the main world, or another.
+ Two,
+ /// Exactly one.
+ One,
+ /// One if there's no capital to trade at; otherwise, maybe.
+ Outpost,
+ None,
+}
+
+impl Rules {
+ /// The rules a system was always made by, before there were kinds.
+ const ORIGINAL: Rules = Rules {
+ planets: 4..=7,
+ belt_width: (1.4, 1.7),
+ settle: 1.0,
+ own_share: 0.7,
+ minority: None,
+ capital: 1.0,
+ dead: false,
+ stations: Stations::Usual,
+ wrecks: 2.5,
+ density: 1.0,
+ richness: 0.6,
+ };
+
+ fn for_profile(p: &Profile) -> Rules {
+ // On a border, the neighbours settle nearly as much as the owners.
+ let (own_share, minority) = match p.neighbour {
+ Some(n) => (0.6, Some(n)),
+ None => (0.92, None),
+ };
+ let base = Rules { own_share, minority, ..Rules::ORIGINAL };
+ match p.kind {
+ SystemKind::Seat => Rules { stations: Stations::Two, ..base },
+ SystemKind::Settled => base,
+ SystemKind::Frontier => Rules {
+ planets: 3..=6,
+ settle: 0.35,
+ capital: 0.55,
+ stations: Stations::Outpost,
+ wrecks: 2.0,
+ ..base
+ },
+ SystemKind::Desolate => Rules {
+ planets: 3..=6,
+ settle: 0.0,
+ capital: 0.0,
+ dead: true,
+ stations: Stations::None,
+ wrecks: 2.0,
+ ..base
+ },
+ SystemKind::Rubble => Rules {
+ planets: 2..=4,
+ belt_width: (2.2, 2.8),
+ settle: 0.6,
+ capital: 0.5,
+ stations: Stations::Two,
+ density: 1.6,
+ richness: 0.85,
+ ..base
+ },
+ SystemKind::Graveyard => {
+ Rules { settle: 0.3, capital: 0.35, stations: Stations::One, wrecks: 6.5, ..base }
+ }
+ }
+ }
+}
+
+pub struct BodyKind {
+ pub name: &'static str,
+ pub rgb: Rgb,
+ /// Drawn with cloud bands.
+ pub banded: bool,
+ /// Radius when you're in orbit around it, in local map units. Not to
+ /// scale; giants are just bigger.
+ pub size: f64,
+ pub giant: bool,
+ /// The chance someone lives there.
+ settled: f64,
+ pub climate: Climate,
+ pub terrain: Terrain,
+ flavour: &'static [&'static str],
+}
+
+/// What a world's ground is, for a city built on it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Terrain {
+ Desert,
+ Ice,
+ Ocean,
+ Jungle,
+ Barren,
+ Forge,
+ /// A giant's: no ground at all. No one builds a city on one.
+ Clouds,
+}
+
+/// How a kind of world holds heat, and what its air is like. Only for
+/// show: see `space::survey`.
+pub struct Climate {
+ /// How much starlight it reflects, from 0 to 1.
+ pub albedo: f64,
+ /// How much warmer it is than bare rock would be at the same distance,
+ /// in kelvin: heat its air traps, or its insides give off.
+ pub warmth: f64,
+ /// A few ways of putting what the air is like. Empty if there's none.
+ pub air: &'static [&'static str],
+ /// Whether people could breathe it, if it weren't too hot or cold.
+ pub breathable: bool,
+}
+
+macro_rules! kind {
+ ($name:expr, $terrain:expr, $rgb:expr, $size:expr, $settled:expr, $climate:expr,
+ $($flavour:expr),+) => {
+ BodyKind { name: $name, rgb: $rgb, banded: false, size: $size, giant: false,
+ settled: $settled, climate: $climate, terrain: $terrain,
+ flavour: &[$($flavour),+] }
+ };
+}
+
+macro_rules! climate {
+ ($albedo:expr, $warmth:expr, $breathable:expr, [$($air:expr),*]) => {
+ Climate { albedo: $albedo, warmth: $warmth, breathable: $breathable, air: &[$($air),*] }
+ };
+}
+
+const DESERT: BodyKind = kind!(
+ "desert world",
+ Terrain::Desert,
+ (214, 172, 104),
+ 10.0,
+ 0.7,
+ climate!(0.25, 35.0, true, ["thin, dry and dusty", "dry; it smells of hot stone"]),
+ "Wind-carved dunes run unbroken from pole to pole.",
+ "Glass storms scour the plains at dusk.",
+ "Under the sand lie the bones of something enormous."
+);
+const ICE: BodyKind = kind!(
+ "ice world",
+ Terrain::Ice,
+ (190, 222, 240),
+ 9.0,
+ 0.3,
+ climate!(0.6, 8.0, false, ["thin and bitter", "a whisper of nitrogen"]),
+ "Blue glaciers grind slowly over a buried sea.",
+ "Its auroras whistle and sing on the comm bands."
+);
+const OCEAN: BodyKind = kind!(
+ "ocean world",
+ Terrain::Ocean,
+ (70, 124, 205),
+ 11.0,
+ 0.6,
+ climate!(0.3, 33.0, true, ["thick, humid and salty", "warm, wet and briny"]),
+ "One endless ocean, broken by a scatter of black islands.",
+ "Kelp forests a hundred metres tall sway beneath the waves."
+);
+const JUNGLE: BodyKind = kind!(
+ "jungle world",
+ Terrain::Jungle,
+ (84, 164, 84),
+ 11.0,
+ 0.5,
+ climate!(0.25, 40.0, true, ["dense, wet, spore-laden", "heavy and green-smelling"]),
+ "Canopy covers everything; nothing on the ground has seen the sun.",
+ "The air is thick with spores and slow-drifting seed-kites."
+);
+const BARREN: BodyKind = kind!(
+ "barren rock",
+ Terrain::Barren,
+ (146, 136, 124),
+ 7.0,
+ 0.15,
+ climate!(0.12, 0.0, false, []),
+ "Cratered grey rock, airless and silent.",
+ "Ancient mine shafts riddle the crust."
+);
+const FORGE: BodyKind = kind!(
+ "forge world",
+ Terrain::Forge,
+ (200, 86, 60),
+ 9.0,
+ 0.4,
+ climate!(0.15, 220.0, false, ["sulphurous and choking", "thin, hot and ashy"]),
+ "Rivers of lava light up the night side.",
+ "The crust is thin and restless, and the ground shakes daily."
+);
+const GAS_GIANT: BodyKind = BodyKind {
+ banded: true,
+ giant: true,
+ ..kind!(
+ "gas giant",
+ Terrain::Clouds,
+ (206, 154, 112),
+ 20.0,
+ 0.25,
+ climate!(0.34, 45.0, false, ["hydrogen and helium", "hydrogen, streaked with ammonia"]),
+ "Storms the size of worlds turn in its banded clouds.",
+ "Cloud-skimmers dive into its upper layers for fuel."
+ )
+};
+const ICE_GIANT: BodyKind = BodyKind {
+ banded: true,
+ giant: true,
+ ..kind!(
+ "ice giant",
+ Terrain::Clouds,
+ (120, 200, 210),
+ 16.0,
+ 0.1,
+ climate!(0.3, 15.0, false, ["hydrogen and methane haze", "cold hydrogen, faintly blue"]),
+ "A pale blue-green ball of methane haze, cold and still.",
+ "Diamond hail falls forever through its depths."
+ )
+};
+
+/// What a planet `a` AU out is likely to be, given where the habitable zone
+/// (`hz`) and the frost line are.
+fn planet_kinds(a: f64, hz: f64) -> &'static [&'static BodyKind] {
+ if a < 0.75 * hz {
+ &[&FORGE, &BARREN, &DESERT]
+ } else if a < 1.7 * hz {
+ &[&OCEAN, &JUNGLE, &DESERT, &DESERT]
+ } else if a < FROST_LINE * hz {
+ &[&ICE, &BARREN, &DESERT]
+ } else {
+ &[&GAS_GIANT, &GAS_GIANT, &ICE_GIANT]
+ }
+}
+
+/// The same, in a dead system: no oceans or jungles, only rock and ice.
+fn dead_kinds(a: f64, hz: f64) -> &'static [&'static BodyKind] {
+ if a < 0.75 * hz {
+ &[&FORGE, &BARREN, &BARREN]
+ } else if a < 1.7 * hz {
+ &[&BARREN, &DESERT, &BARREN]
+ } else if a < FROST_LINE * hz {
+ &[&ICE, &BARREN]
+ } else {
+ &[&GAS_GIANT, &ICE_GIANT, &ICE_GIANT]
+ }
+}
+
+/// Beyond this many habitable-zone distances out, water freezes and giants form.
+const FROST_LINE: f64 = 2.7;
+
+/// What a moon is likely to be, inside the frost line and beyond it. Forge
+/// moons are the ones a giant kneads hot with its tides.
+const MOON_KINDS: [&[&BodyKind]; 2] =
+ [&[&BARREN, &BARREN, &DESERT, &FORGE], &[&ICE, &ICE, &BARREN, &FORGE]];
+
+fn settlements(culture: Culture) -> &'static [&'static str] {
+ match culture {
+ Culture::Qaari => &[
+ "Qaari caravan-cities crawl across the surface on great tracked platforms.",
+ "The Qaari keep a water-court here, where oaths are sworn over cisterns.",
+ ],
+ Culture::Cant => &[
+ "Foundry-cathedrals of the Cant hum day and night with litanies.",
+ "The Cant strip old hulls here, and sing over the parts.",
+ ],
+ Culture::Houses => &[
+ "A High House palace sprawls across the one temperate valley.",
+ "House banners hang from the spires of a lofty court-city.",
+ ],
+ Culture::Drift => &[
+ "A tangle of lashed-together hulls hangs in orbit: a Drift anchorage.",
+ "The Drift trade here, from a market of docked ships.",
+ ],
+ }
+}
+
+const UNSETTLED: &[&str] = &[
+ "No one lives here. At night the only lights are the stars.",
+ "A survey beacon still blinks on the surface, long abandoned.",
+];
+
+pub struct Planet {
+ pub name: String,
+ pub kind: &'static BodyKind,
+ /// Who lives there, if anyone.
+ pub people: Option<Culture>,
+ pub orbit: Orbit,
+ /// In suns.
+ pub mass: f64,
+ pub moons: Vec<Moon>,
+ /// A few sentences for the landing screen.
+ pub description: Vec<String>,
+}
+
+pub struct Moon {
+ pub name: String,
+ pub kind: &'static BodyKind,
+ pub people: Option<Culture>,
+ /// Around its planet.
+ pub orbit: Orbit,
+ pub description: Vec<String>,
+}
+
+pub struct Belt {
+ pub name: String,
+ pub inner: f64,
+ pub outer: f64,
+ /// The stretch of belt you can travel to, which goes round with it.
+ pub region: Orbit,
+ /// How many rocks there are, against an ordinary belt.
+ pub density: f64,
+ /// The chance a rock has ore in it.
+ pub richness: f64,
+}
+
+pub struct Station {
+ pub name: String,
+ pub culture: Culture,
+ /// The planet whose Lagrange point it sits at.
+ pub planet: usize,
+ /// At L4, sixty degrees ahead of the planet, rather than L5, behind it.
+ pub leading: bool,
+ pub description: Vec<String>,
+}
+
+/// A derelict with its distress beacon calling. Wrecks come and go: each
+/// drifts in on some day, calls for a month or so, and then its beacon fails
+/// and it's lost to the dark.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Wreck {
+ /// Board it and this seeds the derelict you find.
+ pub seed: u64,
+ pub name: String,
+ /// "Cant hauler".
+ pub description: String,
+ /// Which place it drifts in.
+ pub place: usize,
+ /// The day its beacon started calling.
+ pub arrived: f64,
+ /// The day its beacon fails and it's gone.
+ pub leaves: f64,
+}
+
+impl Wreck {
+ pub fn calling(&self, day: f64) -> bool {
+ self.arrived <= day && day < self.leaves
+ }
+}
+
+/// Wrecks turn up in batches, one batch for every this many days.
+const WRECK_EPOCH: f64 = 10.0;
+/// How long a beacon calls, in days.
+const BEACON_LIFE: (f64, f64) = (20.0, 45.0);
+/// The most wrecks one batch can bring.
+const WRECK_TRIES: usize = 6;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum PlaceKind {
+ /// Orbit around a planet.
+ Planet(usize),
+ /// Orbit around a planet's moon.
+ Moon(usize, usize),
+ Belt,
+ Station(usize),
+}
+
+/// What there is for you when you land or dock somewhere.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Landing {
+ /// People live on a world with ground to land on: you set down outside
+ /// their capital and walk in, to a full set of shops.
+ Capital(Culture),
+ /// A station: dock and sell ore, and that's all.
+ Market(Culture),
+ /// People live in a giant's clouds, but there's no ground to land on.
+ Aloft(Culture),
+ /// A world no one lives on: land and look around, with no one to trade with.
+ Wilds,
+ /// Nowhere to land at all: the belt, or an empty giant.
+ Nowhere,
+}
+
+impl Landing {
+ /// Whether anyone here buys ore.
+ pub fn trades(self) -> bool {
+ matches!(self, Landing::Capital(_) | Landing::Market(_))
+ }
+}
+
+pub struct Place {
+ pub kind: PlaceKind,
+ /// "Qeth Amarul", "the Zirim Belt".
+ pub name: String,
+}
+
+pub struct System {
+ pub seed: u64,
+ pub name: String,
+ /// Whose space this is; most settled worlds here are theirs.
+ pub culture: Culture,
+ pub kind: SystemKind,
+ pub star: &'static StarKind,
+ pub planets: Vec<Planet>,
+ /// The system's main world: in a seat, the homeworld of its people,
+ /// where you start. In a system no one lives in, the world likeliest to
+ /// have been settled.
+ pub home: usize,
+ pub belt: Belt,
+ pub stations: Vec<Station>,
+ /// Everywhere you can travel to, outward from the star.
+ pub places: Vec<Place>,
+ /// How many wrecks are calling at once, on average.
+ wreck_rate: f64,
+ /// On a battlefield, the place most wrecks turn up.
+ battle: Option<usize>,
+ /// Batches of wrecks already made, by epoch: naming one means
+ /// generating its whole ship.
+ batches: RefCell<BTreeMap<i64, Vec<Wreck>>>,
+}
+
+impl System {
+ /// Where a place is on `day`, in AU from the star.
+ pub fn position(&self, place: usize, day: f64) -> Vec2 {
+ match self.places[place].kind {
+ PlaceKind::Planet(p) => self.planets[p].orbit.position(day),
+ PlaceKind::Moon(p, m) => {
+ self.planets[p].orbit.position(day) + self.planets[p].moons[m].orbit.position(day)
+ }
+ PlaceKind::Belt => self.belt.region.position(day),
+ PlaceKind::Station(s) => {
+ let station = &self.stations[s];
+ let lead = if station.leading { PI / 3.0 } else { -PI / 3.0 };
+ self.planets[station.planet].orbit.position(day).rotated(lead)
+ }
+ }
+ }
+
+ /// What there is for you if you land or dock at a place.
+ pub fn landing(&self, place: usize) -> Landing {
+ let world = |kind: &BodyKind, people: Option<Culture>| match (people, kind.giant) {
+ (Some(c), true) => Landing::Aloft(c),
+ (Some(c), false) => Landing::Capital(c),
+ (None, true) => Landing::Nowhere,
+ (None, false) => Landing::Wilds,
+ };
+ match self.places[place].kind {
+ PlaceKind::Planet(p) => world(self.planets[p].kind, self.planets[p].people),
+ PlaceKind::Moon(p, m) => {
+ let moon = &self.planets[p].moons[m];
+ world(moon.kind, moon.people)
+ }
+ PlaceKind::Station(s) => Landing::Market(self.stations[s].culture),
+ PlaceKind::Belt => Landing::Nowhere,
+ }
+ }
+
+ /// The place for orbit around the homeworld, where journeys start.
+ pub fn home_place(&self) -> usize {
+ self.places.iter().position(|p| p.kind == PlaceKind::Planet(self.home)).unwrap()
+ }
+
+ /// Whether the main world is the homeworld of a people, not just the
+ /// biggest place in the system.
+ pub fn is_homeworld(&self, planet: usize) -> bool {
+ planet == self.home && self.kind == SystemKind::Seat
+ }
+
+ /// Where a jump brings you out: the outermost planet, or the belt if it
+ /// lies further out.
+ pub fn edge_place(&self) -> usize {
+ self.places
+ .iter()
+ .rposition(|p| matches!(p.kind, PlaceKind::Planet(_) | PlaceKind::Belt))
+ .unwrap()
+ }
+
+ /// Whether you can buy fuel at a place: at a station's dock, or a
+ /// city's shipyard.
+ pub fn sells_fuel_at(&self, place: usize) -> bool {
+ self.landing(place).trades()
+ }
+
+ /// Whether anywhere in the system sells fuel.
+ pub fn sells_fuel(&self) -> bool {
+ (0..self.places.len()).any(|i| self.sells_fuel_at(i))
+ }
+
+ /// Where to go for fuel: the first station, or failing that the first
+ /// capital.
+ pub fn fuel_place(&self) -> Option<usize> {
+ let station = |i: &usize| matches!(self.landing(*i), Landing::Market(_));
+ (0..self.places.len())
+ .find(station)
+ .or_else(|| (0..self.places.len()).find(|&i| self.sells_fuel_at(i)))
+ }
+
+ /// How far out anything goes, in AU.
+ pub fn extent(&self) -> f64 {
+ let planets = self.planets.iter().map(|p| p.orbit.apoapsis_distance());
+ planets.fold(self.belt.outer, f64::max)
+ }
+
+ /// The wrecks whose beacons are calling on `day`, oldest first.
+ pub fn wrecks_on(&self, day: f64) -> Vec<Wreck> {
+ let first = ((day - BEACON_LIFE.1) / WRECK_EPOCH).floor() as i64;
+ let last = (day / WRECK_EPOCH).floor() as i64;
+ let mut wrecks: Vec<Wreck> =
+ (first..=last).flat_map(|epoch| self.batch(epoch)).filter(|w| w.calling(day)).collect();
+ wrecks.sort_by(|a, b| a.arrived.total_cmp(&b.arrived));
+ wrecks
+ }
+
+ /// The wrecks calling on `day` that you haven't stripped yet.
+ pub fn beacons(&self, day: f64, stripped: &HashSet<u64>) -> Vec<Wreck> {
+ let mut wrecks = self.wrecks_on(day);
+ wrecks.retain(|w| !stripped.contains(&w.seed));
+ wrecks
+ }
+
+ /// The wrecks you find at `place` on `day`, having set out for it on
+ /// `departed`: those calling now, and those that were calling when you
+ /// set out, even if their beacons have failed on the way, since you
+ /// came by their last fix. Not those you've stripped. Oldest first.
+ pub fn wrecks_at(
+ &self,
+ place: usize,
+ day: f64,
+ departed: Option<f64>,
+ stripped: &HashSet<u64>,
+ ) -> Vec<Wreck> {
+ let mut wrecks = self.beacons(day, stripped);
+ if let Some(departed) = departed.filter(|&d| d < day) {
+ for w in self.beacons(departed, stripped) {
+ if !wrecks.iter().any(|x| x.seed == w.seed) {
+ wrecks.push(w);
+ }
+ }
+ }
+ wrecks.retain(|w| w.place == place);
+ wrecks.sort_by(|a, b| a.arrived.total_cmp(&b.arrived));
+ wrecks
+ }
+
+ /// The wrecks that turned up in one epoch, whenever they leave.
+ fn batch(&self, epoch: i64) -> Vec<Wreck> {
+ if let Some(batch) = self.batches.borrow().get(&epoch) {
+ return batch.clone();
+ }
+ let batch_seed = seed::derive(self.seed, "wrecks", epoch as u64);
+ let mut rng = seed::rng(batch_seed);
+ // Enough turn up, for as long as they last, that `wreck_rate` are
+ // calling at any one time.
+ let expected = self.wreck_rate * WRECK_EPOCH / ((BEACON_LIFE.0 + BEACON_LIFE.1) / 2.0);
+ let chance = (expected / WRECK_TRIES as f64).min(1.0);
+ let count = (0..WRECK_TRIES).filter(|_| rng.random_bool(chance)).count();
+ let batch: Vec<Wreck> = (0..count)
+ .map(|i| {
+ let seed = seed::derive(batch_seed, "wreck", i as u64);
+ let (name, description) = crawl::describe(seed);
+ let place = match self.battle {
+ Some(b) if rng.random_bool(0.6) => b,
+ _ => rng.random_range(..self.places.len()),
+ };
+ let arrived = (epoch as f64 + rng.random_range(0.0..1.0)) * WRECK_EPOCH;
+ let leaves = arrived + rng.random_range(BEACON_LIFE.0..BEACON_LIFE.1);
+ Wreck { seed, name, description, place, arrived, leaves }
+ })
+ .collect();
+ self.batches.borrow_mut().insert(epoch, batch.clone());
+ batch
+ }
+}
+
+/// The system a universe has always started you in, made from its seed
+/// alone: who holds it, its name and its star come from the seed too.
+pub fn generate(seed: u64) -> System {
+ build(seed, None)
+}
+
+/// A system the galaxy has already decided things about.
+pub fn generate_as(seed: u64, profile: &Profile) -> System {
+ build(seed, Some(profile))
+}
+
+// Without a profile, this must draw from the RNG exactly as it always has,
+// or the system every old universe started in would change under its saves.
+fn build(seed: u64, profile: Option<&Profile>) -> System {
+ let mut rng = seed::rng(seed);
+ let (culture, system_name, star, kind, rules) = match profile {
+ None => {
+ let culture = random_culture(&mut rng);
+ let name = culture.word(&mut rng);
+ let star = STARS.choose_weighted(&mut rng, |s| s.weight).unwrap();
+ (culture, name, star, SystemKind::Seat, Rules::ORIGINAL)
+ }
+ Some(p) => (p.culture, p.name.clone(), p.star, p.kind, Rules::for_profile(p)),
+ };
+ let hz = star.luminosity.sqrt();
+
+ // Orbits first, so the homeworld can be chosen before names are given.
+ let count = rng.random_range(rules.planets.clone());
+ let mut orbits: Vec<(Orbit, &'static BodyKind, f64)> = Vec::new();
+ let mut belt_span = None;
+ let mut a = hz * rng.random_range(0.3..0.55);
+ let mut outermost = 0.0;
+ for i in 0..count {
+ if i > 0 {
+ a *= rng.random_range(1.5..2.0);
+ }
+ if belt_span.is_none() && i > 0 && a > FROST_LINE * hz {
+ let inner: f64 = outermost * 1.3;
+ let outer = inner * rng.random_range(rules.belt_width.0..rules.belt_width.1);
+ belt_span = Some((inner, outer));
+ a = a.max(outer * 1.45);
+ }
+ // Keep each orbit well clear of the last, however eccentric.
+ let mut e = if rng.random_bool(0.15) {
+ rng.random_range(0.12..0.22)
+ } else {
+ rng.random_range(0.0..0.08)
+ };
+ while i > 0 && e > 1e-6 && a * (1.0 - e) < outermost * 1.15 {
+ e *= 0.5;
+ }
+ let kinds = if rules.dead { dead_kinds(a, hz) } else { planet_kinds(a, hz) };
+ let kind = *kinds.choose(&mut rng).unwrap();
+ let mass = EARTH_MASS
+ * if kind.giant { rng.random_range(15.0..320.0) } else { rng.random_range(0.05..2.5) };
+ let orbit =
+ Orbit::new(a, e, rng.random_range(0.0..TAU), rng.random_range(0.0..TAU), star.mass);
+ outermost = orbit.apoapsis_distance();
+ orbits.push((orbit, kind, mass));
+ }
+ let (inner, outer) = belt_span.unwrap_or_else(|| {
+ let inner = outermost * 1.3;
+ let width = if rules.belt_width == Rules::ORIGINAL.belt_width {
+ 1.5
+ } else {
+ rng.random_range(rules.belt_width.0..rules.belt_width.1)
+ };
+ (inner, inner * width)
+ });
+ let region = Orbit::new((inner + outer) / 2.0, 0.0, 0.0, rng.random_range(0.0..TAU), star.mass);
+ let word = culture.word(&mut rng);
+ let belt = Belt {
+ name: if kind == SystemKind::Rubble {
+ format!("the {word} Shoals")
+ } else {
+ format!("the {word} Belt")
+ },
+ inner,
+ outer,
+ region,
+ density: rules.density,
+ richness: rules.richness,
+ };
+
+ // Home is the habitable-zone world nearest the middle of the zone.
+ let home = (0..orbits.len())
+ .filter(|&i| !orbits[i].1.giant)
+ .min_by(|&i, &j| {
+ let off = |k: usize| (orbits[k].0.a / hz).ln().abs();
+ off(i).total_cmp(&off(j))
+ })
+ .unwrap_or(0);
+ // And it's always somewhere people could live, unless nothing lives here.
+ if !rules.dead {
+ orbits[home].1 = *[&OCEAN, &JUNGLE, &DESERT].choose(&mut rng).unwrap();
+ }
+ let capital = rules.capital >= 1.0 || rng.random_bool(rules.capital);
+
+ let mut planets = Vec::new();
+ for (i, (orbit, kind, mass)) in orbits.into_iter().enumerate() {
+ let people = if i == home {
+ capital.then_some(culture)
+ } else {
+ settle(kind, culture, &rules, &mut rng)
+ };
+ let name = match people {
+ Some(c) => c.word(&mut rng),
+ None => format!("{system_name} {}", roman(i + 1)),
+ };
+ let moon_count =
+ if kind.giant { rng.random_range(1..=4) } else { usize::from(rng.random_bool(0.4)) };
+ let mut moon_a = (if kind.giant { 0.002 } else { 0.0012 }) * rng.random_range(1.0..1.6);
+ // Moons only stay put well inside the planet's Hill sphere, where
+ // its pull beats the star's.
+ let hill = orbit.periapsis_distance() * (mass / (3.0 * star.mass)).cbrt();
+ let frozen = orbit.a > FROST_LINE * hz;
+ let mut moons = Vec::new();
+ for _ in 0..moon_count {
+ if moon_a > 0.4 * hill {
+ break;
+ }
+ let kind = *MOON_KINDS[usize::from(frozen)].choose(&mut rng).unwrap();
+ let people = settle(kind, culture, &rules, &mut rng).filter(|_| rng.random_bool(0.5));
+ let moon_name = match people {
+ Some(c) => c.word(&mut rng),
+ None => culture.word(&mut rng),
+ };
+ let orbit = Orbit::new(
+ moon_a,
+ rng.random_range(0.0..0.05),
+ rng.random_range(0.0..TAU),
+ rng.random_range(0.0..TAU),
+ mass,
+ );
+ moons.push(Moon {
+ name: moon_name,
+ kind,
+ people,
+ orbit,
+ description: describe(kind, people, &mut rng),
+ });
+ moon_a *= rng.random_range(1.6..2.2);
+ }
+ planets.push(Planet {
+ name,
+ kind,
+ people,
+ orbit,
+ mass,
+ moons,
+ description: describe(kind, people, &mut rng),
+ });
+ }
+
+ let stations = stations(&planets, home, culture, &rules, &mut rng);
+ let places = list_places(&planets, &belt, &stations);
+ // On a battlefield, most of the wrecks turn up in one place: where the
+ // fighting was thickest, and still draws scavengers and the lost.
+ let battle = (kind == SystemKind::Graveyard)
+ .then(|| seed::rng(seed::derive(seed, "battle", 0)).random_range(..places.len()));
+
+ System {
+ seed,
+ name: system_name,
+ culture,
+ kind,
+ star,
+ planets,
+ home,
+ belt,
+ stations,
+ places,
+ wreck_rate: rules.wrecks,
+ battle,
+ batches: RefCell::new(BTreeMap::new()),
+ }
+}
+
+fn settle(kind: &BodyKind, culture: Culture, rules: &Rules, rng: &mut Rng) -> Option<Culture> {
+ rng.random_bool((kind.settled * rules.settle).min(1.0)).then(|| {
+ if rng.random_bool(rules.own_share) {
+ culture
+ } else {
+ rules.minority.unwrap_or_else(|| random_culture(rng))
+ }
+ })
+}
+
+/// Stations at Lagrange points: usually one at the heaviest planet, and
+/// often one at the homeworld's too.
+fn stations(
+ planets: &[Planet],
+ home: usize,
+ culture: Culture,
+ rules: &Rules,
+ rng: &mut Rng,
+) -> Vec<Station> {
+ let heaviest =
+ (0..planets.len()).max_by(|&i, &j| planets[i].mass.total_cmp(&planets[j].mass)).unwrap();
+ let capital = planets[home].people.is_some() && !planets[home].kind.giant;
+ let mut anchors = vec![heaviest];
+ match rules.stations {
+ Stations::Usual => {
+ if heaviest != home && rng.random_bool(0.6) {
+ anchors.push(home);
+ }
+ }
+ Stations::Two => {
+ let second = if heaviest != home {
+ Some(home)
+ } else {
+ (0..planets.len())
+ .filter(|&p| p != heaviest)
+ .max_by_key(|&p| planets[p].moons.len())
+ };
+ anchors.extend(second);
+ }
+ Stations::One => {}
+ Stations::Outpost => {
+ if capital && rng.random_bool(0.5) {
+ anchors.clear();
+ }
+ }
+ Stations::None => anchors.clear(),
+ }
+ anchors
+ .into_iter()
+ .enumerate()
+ .map(|(i, p)| {
+ // Over a border, the neighbours may keep the second station.
+ let who = match rules.minority {
+ Some(n) if i > 0 && rng.random_bool(0.5) => n,
+ _ => culture,
+ };
+ station(p, &planets[p], who, rng)
+ })
+ .collect()
+}
+
+fn describe(kind: &BodyKind, people: Option<Culture>, rng: &mut Rng) -> Vec<String> {
+ let settlement = match people {
+ Some(c) => settlements(c).choose(rng).unwrap(),
+ None => UNSETTLED.choose(rng).unwrap(),
+ };
+ vec![kind.flavour.choose(rng).unwrap().to_string(), settlement.to_string()]
+}
+
+fn station(planet: usize, at: &Planet, culture: Culture, rng: &mut Rng) -> Station {
+ let leading = rng.random_bool(0.5);
+ let word = culture.word(rng);
+ let name = match culture {
+ Culture::Qaari => format!("the {word} Caravanserai"),
+ Culture::Cant => format!("Foundry {word}"),
+ Culture::Houses => format!("the {word} Court"),
+ Culture::Drift => format!("{word} Anchorage"),
+ };
+ let (point, side) = if leading { ("L4", "ahead of") } else { ("L5", "behind") };
+ let mut description = vec![format!(
+ "It turns slowly at {}'s {point} point, sixty degrees {side} the planet, \
+ where the pulls of {} and the star balance.",
+ at.name, at.name
+ )];
+ description.push(
+ match culture {
+ Culture::Qaari => {
+ "Its docks smell of spice-tea and hot metal; caravans of ore haulers queue outside."
+ }
+ Culture::Cant => {
+ "Its hull is studded with salvaged plating, every plate engraved with a prayer."
+ }
+ Culture::Houses => {
+ "Liveried pilots wait in its gilded docks, and everyone is watching everyone."
+ }
+ Culture::Drift => {
+ "It's less a station than a raft of docked ships, rearranged every season."
+ }
+ }
+ .to_string(),
+ );
+ Station { name, culture, planet, leading, description }
+}
+
+/// Every place, in order outward: each planet followed by its moons and any
+/// station at its Lagrange points, with the belt where it falls.
+fn list_places(planets: &[Planet], belt: &Belt, stations: &[Station]) -> Vec<Place> {
+ let mut places = Vec::new();
+ let mut belt_listed = false;
+ for (p, planet) in planets.iter().enumerate() {
+ if !belt_listed && planet.orbit.a > belt.region.a {
+ places.push(Place { kind: PlaceKind::Belt, name: belt.name.clone() });
+ belt_listed = true;
+ }
+ places.push(Place { kind: PlaceKind::Planet(p), name: planet.name.clone() });
+ for (m, moon) in planet.moons.iter().enumerate() {
+ places.push(Place { kind: PlaceKind::Moon(p, m), name: moon.name.clone() });
+ }
+ for (s, station) in stations.iter().enumerate().filter(|(_, s)| s.planet == p) {
+ places.push(Place { kind: PlaceKind::Station(s), name: station.name.clone() });
+ }
+ }
+ if !belt_listed {
+ places.push(Place { kind: PlaceKind::Belt, name: belt.name.clone() });
+ }
+ places
+}
+
+fn random_culture(rng: &mut Rng) -> Culture {
+ Culture::ALL[rng.random_range(..Culture::ALL.len())]
+}
+
+fn roman(n: usize) -> &'static str {
+ ["0", "I", "II", "III", "IV", "V", "VI", "VII", "VIII"].get(n).copied().unwrap_or("IX")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn systems_are_sound() {
+ for seed in 0..300 {
+ let s = generate(seed);
+ assert!((4..=7).contains(&s.planets.len()));
+ let mut outermost = 0.0;
+ for p in &s.planets {
+ assert!(
+ p.orbit.periapsis_distance() > outermost,
+ "seed {seed}: {} crosses",
+ p.name
+ );
+ outermost = p.orbit.apoapsis_distance();
+ let hill = p.orbit.periapsis_distance() * (p.mass / (3.0 * s.star.mass)).cbrt();
+ for m in &p.moons {
+ assert!(
+ m.orbit.a < 0.4 * hill,
+ "seed {seed}: {} strays from {}",
+ m.name,
+ p.name
+ );
+ assert!(m.orbit.period < p.orbit.period / 5.0);
+ }
+ }
+ let crosses_belt = |p: &Planet| {
+ p.orbit.apoapsis_distance() > s.belt.inner
+ && p.orbit.periapsis_distance() < s.belt.outer
+ };
+ assert!(
+ !s.planets.iter().any(crosses_belt),
+ "seed {seed}: a planet runs through the belt"
+ );
+ assert_eq!(s.planets[s.home].people, Some(s.culture), "seed {seed}: home is unsettled");
+ assert!(!s.planets[s.home].kind.giant);
+ assert!(!s.stations.is_empty());
+ let belts = s.places.iter().filter(|p| p.kind == PlaceKind::Belt).count();
+ assert_eq!(belts, 1);
+ for w in s.wrecks_on(0.0) {
+ assert!(w.place < s.places.len());
+ assert!(w.calling(0.0));
+ }
+ }
+ }
+
+ #[test]
+ fn capitals_need_people_and_ground() {
+ for seed in 0..100 {
+ let s = generate(seed);
+ assert_eq!(s.landing(s.home_place()), Landing::Capital(s.culture));
+ for (i, place) in s.places.iter().enumerate() {
+ let landing = s.landing(i);
+ match place.kind {
+ PlaceKind::Planet(p) if s.planets[p].kind.giant => {
+ assert!(matches!(landing, Landing::Aloft(_) | Landing::Nowhere));
+ }
+ PlaceKind::Station(_) => assert!(matches!(landing, Landing::Market(_))),
+ PlaceKind::Belt => assert_eq!(landing, Landing::Nowhere),
+ _ => {}
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn places_are_listed_outward() {
+ let s = generate(8);
+ let distances: Vec<f64> = (0..s.places.len())
+ .filter(|&i| matches!(s.places[i].kind, PlaceKind::Planet(_) | PlaceKind::Belt))
+ .map(|i| match s.places[i].kind {
+ PlaceKind::Belt => s.belt.region.a,
+ PlaceKind::Planet(p) => s.planets[p].orbit.a,
+ _ => unreachable!(),
+ })
+ .collect();
+ assert!(distances.windows(2).all(|w| w[0] < w[1]), "{distances:?}");
+ }
+
+ #[test]
+ fn stations_sit_sixty_degrees_from_their_planet() {
+ let s = generate(3);
+ for (i, place) in s.places.iter().enumerate() {
+ if let PlaceKind::Station(k) = place.kind {
+ let planet = s.planets[s.stations[k].planet].orbit.position(40.0);
+ let station = s.position(i, 40.0);
+ let between = (planet.dot(station) / (planet.length() * station.length())).acos();
+ assert!((between - PI / 3.0).abs() < 1e-9);
+ }
+ }
+ }
+
+ #[test]
+ fn same_seed_same_system() {
+ let names = |s: System| s.places.iter().map(|p| p.name.clone()).collect::<Vec<_>>();
+ assert_eq!(names(generate(5)), names(generate(5)));
+ }
+
+ #[test]
+ fn wrecks_come_and_go() {
+ let mut total = 0;
+ let days = 0..400;
+ for seed in 0..6 {
+ let s = generate(seed);
+ for day in days.clone() {
+ let day = day as f64;
+ let here = s.wrecks_on(day);
+ total += here.len();
+ for w in &here {
+ let life = w.leaves - w.arrived;
+ assert!((BEACON_LIFE.0..BEACON_LIFE.1).contains(&life));
+ }
+ }
+ // A wreck calling now is long gone in a few months, and others
+ // have come in its place.
+ let now: Vec<u64> = s.wrecks_on(10.0).iter().map(|w| w.seed).collect();
+ let later = s.wrecks_on(110.0);
+ assert!(later.iter().all(|w| !now.contains(&w.seed)));
+ // The same day always has the same wrecks, cached or not.
+ let again: Vec<u64> = generate(seed).wrecks_on(10.0).iter().map(|w| w.seed).collect();
+ assert_eq!(now, again);
+ }
+ let average = total as f64 / (6 * days.len()) as f64;
+ assert!((2.0..3.0).contains(&average), "{average} wrecks calling at once");
+ }
+
+ #[test]
+ fn a_failed_beacons_last_fix_still_finds_its_wreck() {
+ let s = generate(1);
+ let none = HashSet::new();
+ let wreck = s.wrecks_on(0.0)[0].clone();
+ let later = wreck.leaves + 2.0;
+ let found = |departed| s.wrecks_at(wreck.place, later, departed, &none);
+ assert!(!found(None).contains(&wreck), "its beacon has failed");
+ assert!(found(Some(0.0)).contains(&wreck), "but it called when you set out");
+ assert!(!found(Some(wreck.leaves)).contains(&wreck), "not if it had failed already");
+ assert!(found(Some(0.0)).iter().all(|w| w.place == wreck.place), "only those here");
+ let stripped = HashSet::from([wreck.seed]);
+ assert!(!s.wrecks_at(wreck.place, later, Some(0.0), &stripped).contains(&wreck));
+ }
+
+ #[test]
+ fn graveyards_are_thick_with_wrecks() {
+ let profile = Profile {
+ name: "Ashfall".into(),
+ culture: Culture::Houses,
+ star: &STARS[0],
+ kind: SystemKind::Graveyard,
+ neighbour: None,
+ };
+ let mut total = 0;
+ let mut at_battle = 0;
+ for seed in 0..6 {
+ let s = generate_as(seed, &profile);
+ let battle = s.battle.unwrap();
+ for day in (0..300).step_by(3) {
+ let here = s.wrecks_on(day as f64);
+ total += here.len();
+ at_battle += here.iter().filter(|w| w.place == battle).count();
+ }
+ }
+ let average = total as f64 / (6 * 100) as f64;
+ assert!((5.0..8.0).contains(&average), "{average} wrecks calling at once");
+ assert!(at_battle * 2 > total, "most lie where the fighting was");
+ }
+}
diff --git a/src/town/interior.rs b/src/town/interior.rs
new file mode 100644
index 0000000..4e09333
--- /dev/null
+++ b/src/town/interior.rs
@@ -0,0 +1,1112 @@
+//! Inside a building.
+//!
+//! Inside is drawn at a much closer scale than the street: a house three
+//! rows deep outside is eleven rows deep inside. You come in by a door on
+//! the same side as the one you bumped outside, at the same place along
+//! the wall.
+//!
+//! Rooms come from splitting the floor with walls, each wall with a
+//! doorway, so every room can be reached. Most buildings have a front room
+//! as wide as the building with smaller rooms behind it: a shop's counter,
+//! a tavern's taproom, a temple's nave, a palace's throne room. Houses are
+//! split any which way. Each room is furnished for what it's for, with a
+//! check after every piece that nothing has been cut off, and then the
+//! people go in.
+//!
+//! "Back" is the side away from the door, wherever that is. Rooms are
+//! furnished in a frame turned to match: `u` runs along the back wall and
+//! `v` away from it, so a counter or an altar always stands at the back.
+
+use rand::RngExt;
+use rand::seq::{IndexedRandom, SliceRandom};
+
+use super::words;
+use super::*;
+use crate::seed::Rng;
+
+/// Furniture.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Thing {
+ Table,
+ Chair,
+ Bed,
+ Hearth,
+ Stove,
+ Shelf,
+ Chest,
+ Crate,
+ Barrel,
+ Altar,
+ Pew,
+ Pillar,
+ Statue,
+ Throne,
+ Plant,
+ /// Hot water, in a bathhouse.
+ Bath,
+ Anvil,
+ Forge,
+ Loom,
+ Workbench,
+ /// Something to sit on, in a changing room or a steam room.
+ Bench,
+ /// Weapons, in a guardroom.
+ Rack,
+ Lamp,
+ /// Pieces of ship, in a shipyard.
+ Engine,
+}
+
+impl Thing {
+ /// Seats can be walked over; people sit on them.
+ pub fn walkable(self) -> bool {
+ matches!(self, Thing::Chair | Thing::Pew | Thing::Bench | Thing::Throne)
+ }
+}
+
+/// What a room is for.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Purpose {
+ Living,
+ Bedroom,
+ Kitchen,
+ Store,
+ /// A room of one's own, in a tenement.
+ Lodging,
+ Taproom,
+ Parlour,
+ Cellar,
+ /// A shop's front room.
+ Counter,
+ Ward,
+ Stacks,
+ Assay,
+ Yard,
+ /// Where an armourer makes and mends.
+ Smithy,
+ Hall,
+ Dining,
+ Library,
+ Bedchamber,
+ Gallery,
+ Throne,
+ Council,
+ Treasury,
+ Nave,
+ Vestry,
+ Cell,
+ Shrine,
+ Workroom,
+ Storehouse,
+ Office,
+ Changing,
+ Baths,
+ Steam,
+ Guardroom,
+ Bunks,
+}
+
+struct Room {
+ rect: Rect,
+ purpose: Purpose,
+}
+
+pub struct Interior {
+ pub building: usize,
+ pub culture: Culture,
+ pub kind: Kind,
+ pub site: Site,
+ rooms: Vec<Room>,
+ /// Which room each cell is in, `NO_ROOM` for walls and doorways.
+ room_of: Vec<u8>,
+ /// Where you stand when you come in, just inside the door.
+ pub start: Pos,
+ pub exit: Pos,
+ /// The shop whose counter this is.
+ pub shop: Option<usize>,
+}
+
+const NO_ROOM: u8 = u8::MAX;
+
+impl Interior {
+ pub fn purpose_at(&self, p: Pos) -> Option<Purpose> {
+ if !self.site.contains(p) {
+ return None;
+ }
+ self.rooms.get(usize::from(self.room_of[self.site.index(p)])).map(|r| r.purpose)
+ }
+
+ pub fn room_count(&self) -> usize {
+ self.rooms.len()
+ }
+
+ /// The shop, if `p` is its counter or its keeper.
+ pub fn shop_at(&self, p: Pos) -> Option<usize> {
+ let shop = self.shop?;
+ let keeper = self.site.person_at(p).map(|i| self.site.people[i].role);
+ (self.site.tile(p) == Tile::Counter || keeper == Some(Role::Keeper(shop))).then_some(shop)
+ }
+}
+
+/// A room, turned so its back wall is at `v = -1`.
+#[derive(Clone, Copy)]
+struct Frame {
+ r: Rect,
+ back: Side,
+}
+
+impl Frame {
+ fn len_u(self) -> i32 {
+ if matches!(self.back, Side::North | Side::South) {
+ self.r.width()
+ } else {
+ self.r.height()
+ }
+ }
+
+ fn len_v(self) -> i32 {
+ if matches!(self.back, Side::North | Side::South) {
+ self.r.height()
+ } else {
+ self.r.width()
+ }
+ }
+
+ fn at(self, u: i32, v: i32) -> Pos {
+ let r = self.r;
+ match self.back {
+ Side::North => Pos::new(r.left + u, r.top + v),
+ Side::South => Pos::new(r.left + u, r.bottom - v),
+ Side::West => Pos::new(r.left + v, r.top + u),
+ Side::East => Pos::new(r.right - v, r.top + u),
+ }
+ }
+}
+
+enum Layout {
+ /// Split any which way, into this many rooms or so.
+ Split(usize, usize),
+ /// A front room as wide as the building, `share` of its depth, with
+ /// this many rooms side by side behind it.
+ Hall(f64, usize, usize),
+ Single,
+}
+
+/// How a building of this kind is laid out: the plan, what the front room
+/// is for, and what the rooms behind it are for, in order. If there are
+/// more rooms than purposes, the last purpose repeats.
+fn layout(kind: Kind) -> (Layout, Purpose, &'static [Purpose]) {
+ use Purpose::*;
+ match kind {
+ Kind::House => (Layout::Split(1, 4), Living, &[Bedroom, Kitchen, Bedroom, Store, Bedroom]),
+ Kind::Tenement => (Layout::Split(4, 9), Kitchen, &[Lodging]),
+ Kind::Manor => (
+ Layout::Hall(0.45, 3, 6),
+ Hall,
+ &[Dining, Library, Bedchamber, Gallery, Kitchen, Bedchamber, Store],
+ ),
+ Kind::Palace => (
+ Layout::Hall(0.55, 4, 8),
+ Throne,
+ &[Council, Treasury, Library, Bedchamber, Dining, Gallery, Kitchen, Guardroom],
+ ),
+ Kind::Temple => (Layout::Hall(0.72, 1, 3), Nave, &[Vestry, Cell, Cell]),
+ Kind::Shrine => (Layout::Single, Shrine, &[]),
+ Kind::Workshop => (Layout::Hall(0.62, 1, 2), Workroom, &[Store, Bedroom]),
+ Kind::Warehouse => (Layout::Hall(0.8, 1, 1), Storehouse, &[Office]),
+ Kind::Bathhouse => (Layout::Hall(0.32, 1, 2), Changing, &[Baths, Steam]),
+ Kind::Guardhouse => (Layout::Split(2, 3), Guardroom, &[Bunks, Store]),
+ Kind::Shop(ShopKind::Broker) => (Layout::Hall(0.55, 1, 2), Counter, &[Assay, Store]),
+ Kind::Shop(ShopKind::Doctor) => (Layout::Hall(0.5, 1, 2), Counter, &[Ward, Store]),
+ Kind::Shop(ShopKind::Scholar) => (Layout::Hall(0.5, 1, 2), Counter, &[Stacks]),
+ Kind::Shop(ShopKind::Tavern) => {
+ (Layout::Hall(0.65, 1, 3), Taproom, &[Parlour, Cellar, Kitchen])
+ }
+ Kind::Shop(ShopKind::Shipyard) => (Layout::Hall(0.45, 1, 2), Counter, &[Yard, Store]),
+ Kind::Shop(ShopKind::Armoury) => (Layout::Hall(0.5, 1, 2), Counter, &[Smithy, Store]),
+ }
+}
+
+struct Builder {
+ culture: Culture,
+ kind: Kind,
+ rng: Rng,
+ site: Site,
+ rooms: Vec<Room>,
+ start: Pos,
+ exit: Pos,
+ /// Which side the back wall is on.
+ back: Side,
+ shop: Option<usize>,
+ keeper: Option<String>,
+ /// How many cells you can walk to from the door, to check nothing gets
+ /// cut off as the furniture goes in.
+ reachable: usize,
+}
+
+pub(super) fn build(t: &Town, b: usize) -> Interior {
+ let building = &t.buildings[b];
+ let kind = building.kind;
+ let mut rng = seed::rng(seed::derive(t.seed, "interior", b as u64));
+ let scale = if matches!(kind, Kind::Palace | Kind::Temple | Kind::Manor) { 3.6 } else { 3.4 };
+ let grow = |n: i32, lo: i32, hi: i32| ((f64::from(n) * scale).round() as i32 + 1).clamp(lo, hi);
+ let shopfront = matches!(kind, Kind::Shop(_));
+ let width = grow(building.width(), if shopfront { 17 } else { 13 }, 77);
+ let height = grow(building.height(), if shopfront { 11 } else { 9 }, 29);
+ let width = width + rng.random_range(0..=2);
+
+ // The door in, at the same place along the wall as outside.
+ let along = |at: i32, lo: i32, hi: i32, len: i32| {
+ let k = if hi > lo { f64::from(at - lo) / f64::from(hi - lo) } else { 0.5 };
+ ((1.0 + k * f64::from(len - 3)).round() as i32).clamp(2, len - 3)
+ };
+ let (bx, by) = (building.door.x, building.door.y);
+ let exit = match building.facing {
+ Side::North => Pos::new(along(bx, building.left, building.right, width), 0),
+ Side::South => Pos::new(along(bx, building.left, building.right, width), height - 1),
+ Side::West => Pos::new(0, along(by, building.top, building.bottom, height)),
+ Side::East => Pos::new(width - 1, along(by, building.top, building.bottom, height)),
+ };
+ let (dx, dy) = building.facing.step();
+ let start = exit.offset(-dx, -dy);
+
+ let mut site = Site::new(width, height, Tile::Wall, Tile::Wall, false);
+ site.fill(Rect::new(1, 1, width - 2, height - 2), Tile::Floor);
+ site.set(exit, Tile::Exit);
+ let shop = building.shop;
+ let mut builder = Builder {
+ culture: t.culture,
+ kind,
+ rng,
+ site,
+ rooms: Vec::new(),
+ start,
+ exit,
+ back: building.facing.opposite(),
+ shop,
+ keeper: shop.map(|s| t.shops[s].keeper.clone()),
+ reachable: 0,
+ };
+ builder.divide();
+ builder.reachable = builder.count_reachable();
+ for room in 0..builder.rooms.len() {
+ builder.furnish(room);
+ }
+ builder.residents();
+
+ let Builder { site, rooms, .. } = builder;
+ let mut room_of = vec![NO_ROOM; (width * height) as usize];
+ for (i, room) in rooms.iter().enumerate() {
+ for p in room.rect.cells() {
+ room_of[site.index(p)] = i as u8;
+ }
+ }
+ Interior { building: b, culture: t.culture, kind, site, rooms, room_of, start, exit, shop }
+}
+
+impl Builder {
+ fn tile(&self, p: Pos) -> Tile {
+ self.site.tile(p)
+ }
+
+ fn is_door(&self, p: Pos) -> bool {
+ matches!(self.tile(p), Tile::Doorway | Tile::Exit)
+ }
+
+ /// Whether a wall across `r` at `at` would block a doorway that opens
+ /// onto the end of it.
+ fn can_wall(&self, r: Rect, upright: bool, at: i32) -> bool {
+ let ends = if upright {
+ [Pos::new(at, r.top - 1), Pos::new(at, r.bottom + 1)]
+ } else {
+ [Pos::new(r.left - 1, at), Pos::new(r.right + 1, at)]
+ };
+ !ends.iter().any(|&p| self.is_door(p))
+ }
+
+ /// Builds a wall across `r`, upright (a column) or not (a row), with a
+ /// doorway in it somewhere in `doors`. Returns the rooms either side.
+ fn wall(
+ &mut self,
+ r: Rect,
+ upright: bool,
+ at: i32,
+ doors: std::ops::RangeInclusive<i32>,
+ ) -> (Rect, Rect) {
+ let cells: Vec<Pos> = if upright {
+ (r.top..=r.bottom).map(|y| Pos::new(at, y)).collect()
+ } else {
+ (r.left..=r.right).map(|x| Pos::new(x, at)).collect()
+ };
+ for &p in &cells {
+ self.site.set(p, Tile::Wall);
+ }
+ let options: Vec<Pos> = cells
+ .iter()
+ .copied()
+ .filter(|p| {
+ let k = if upright { p.y } else { p.x };
+ doors.contains(&k)
+ })
+ .collect();
+ let door = options.choose(&mut self.rng).or(cells.get(cells.len() / 2)).copied();
+ if let Some(door) = door {
+ self.site.set(door, Tile::Doorway);
+ }
+ if upright {
+ (
+ Rect::new(r.left, r.top, at - 1, r.bottom),
+ Rect::new(at + 1, r.top, r.right, r.bottom),
+ )
+ } else {
+ (
+ Rect::new(r.left, r.top, r.right, at - 1),
+ Rect::new(r.left, at + 1, r.right, r.bottom),
+ )
+ }
+ }
+
+ /// Splits the floor into rooms.
+ fn divide(&mut self) {
+ let floor = Rect::new(1, 1, self.site.width - 2, self.site.height - 2);
+ let (plan, front, behind) = layout(self.kind);
+ let mut rects = Vec::new();
+ match plan {
+ Layout::Single => rects.push(floor),
+ Layout::Split(lo, hi) => {
+ let area = floor.area() as usize;
+ let most = (area / 40).max(1);
+ let target = self.rng.random_range(lo..=hi).min(most).max(lo.min(most));
+ self.split(floor, target, &mut rects);
+ }
+ Layout::Hall(share, lo, hi) => self.hall(floor, share, (lo, hi), &mut rects),
+ }
+ // The front room is the one you walk into.
+ let first = rects.iter().position(|r| r.contains(self.start)).unwrap_or(0);
+ let front_rect = rects.remove(first);
+ self.rooms.push(Room { rect: front_rect, purpose: front });
+ for (i, rect) in rects.into_iter().enumerate() {
+ let purpose = behind.get(i).or(behind.last()).copied().unwrap_or(front);
+ self.rooms.push(Room { rect, purpose });
+ }
+ }
+
+ fn split(&mut self, r: Rect, rooms: usize, out: &mut Vec<Rect>) {
+ let (min_w, min_h) = (4, 3);
+ if rooms <= 1 {
+ out.push(r);
+ return;
+ }
+ let wide =
+ f64::from(r.width()) > 2.0 * f64::from(r.height()) * self.rng.random_range(0.7..1.4);
+ for upright in [wide, !wide] {
+ let (lo, hi) = if upright {
+ (r.left + min_w, r.right - min_w)
+ } else {
+ (r.top + min_h, r.bottom - min_h)
+ };
+ let options: Vec<i32> = (lo..=hi).filter(|&at| self.can_wall(r, upright, at)).collect();
+ let Some(&at) = options.choose(&mut self.rng) else { continue };
+ let span = if upright { r.top + 1..=r.bottom - 1 } else { r.left + 1..=r.right - 1 };
+ let (a, b) = self.wall(r, upright, at, span);
+ // Share the rooms out by size.
+ let share = a.area() as f64 / (a.area() + b.area()).max(1) as f64;
+ let first = ((rooms as f64 * share).round() as usize).clamp(1, rooms - 1);
+ self.split(a, first, out);
+ self.split(b, rooms - first, out);
+ return;
+ }
+ out.push(r);
+ }
+
+ fn hall(&mut self, floor: Rect, share: f64, (lo, hi): (usize, usize), out: &mut Vec<Rect>) {
+ let f = Frame { r: floor, back: self.back };
+ let (len_u, len_v) = (f.len_u(), f.len_v());
+ let upright = matches!(self.back, Side::West | Side::East);
+ // The back rooms take `v` from 0 to `k - 1`, then comes the wall,
+ // then the front room, at least four deep for a counter.
+ let least = if upright { 4 } else { 2 };
+ if len_v < least + 5 {
+ out.push(floor);
+ return;
+ }
+ let k = ((f64::from(len_v) * (1.0 - share)).round() as i32).clamp(least, len_v - 5);
+ let at = if upright { f.at(0, k).x } else { f.at(0, k).y };
+ // Temples and palaces keep the middle of the wall for the altar or
+ // throne, so their doorway is off to one side.
+ let off_centre = matches!(self.kind, Kind::Temple | Kind::Palace);
+ let span = if upright {
+ floor.top + 1..=floor.bottom - 1
+ } else {
+ floor.left + 1..=floor.right - 1
+ };
+ let span = if off_centre {
+ let (a, b) = (*span.start(), *span.end());
+ let quarter = ((b - a) / 5).max(1);
+ if self.rng.random_bool(0.5) { a..=a + quarter } else { b - quarter..=b }
+ } else {
+ span
+ };
+ let (one, two) = self.wall(floor, upright, at, span);
+ let (back_rect, front_rect) =
+ if one.contains(f.at(0, 0)) { (one, two) } else { (two, one) };
+ out.push(front_rect);
+ // Rooms side by side behind: split across `u`.
+ let min_u = if upright { 3 } else { 5 };
+ let most = (len_u / (min_u + 1)).max(1) as usize;
+ let n = self.rng.random_range(lo..=hi).min(most);
+ let mut rest = back_rect;
+ for i in (1..n).rev() {
+ // Cut off one room from the far end of `rest`, sized to leave
+ // the others about as big.
+ let (from, to) =
+ if upright { (rest.top, rest.bottom) } else { (rest.left, rest.right) };
+ let size = (to - from + 1) / (i as i32 + 1);
+ let ideal = to - size;
+ let options: Vec<i32> = (ideal - 2..=ideal + 2)
+ .filter(|&a| a - from >= min_u && to - a >= min_u)
+ .filter(|&a| self.can_wall(rest, !upright, a))
+ .collect();
+ let Some(&a) = options.choose(&mut self.rng) else { break };
+ let span = if upright { rest.left..=rest.right } else { rest.top..=rest.bottom };
+ let (near, far) = self.wall(rest, !upright, a, span);
+ out.push(far);
+ rest = near;
+ }
+ out.push(rest);
+ }
+
+ fn count_reachable(&self) -> usize {
+ self.site.reachable(self.start).iter().filter(|&&seen| seen).count()
+ }
+
+ /// Puts a piece of furniture on a bare floor cell, unless it would
+ /// crowd a doorway or cut off part of the building.
+ fn put(&mut self, p: Pos, thing: Thing) -> bool {
+ if self.tile(p) != Tile::Floor || p.neighbours().any(|n| self.is_door(n)) || p == self.start
+ {
+ return false;
+ }
+ self.site.set(p, Tile::Furniture(thing));
+ if thing.walkable() {
+ return true;
+ }
+ let now = self.count_reachable();
+ if now + 1 == self.reachable {
+ self.reachable = now;
+ true
+ } else {
+ self.site.set(p, Tile::Floor);
+ false
+ }
+ }
+
+ fn frame(&self, room: usize) -> Frame {
+ Frame { r: self.rooms[room].rect, back: self.back }
+ }
+
+ fn floor_of(&self, room: usize) -> Vec<Pos> {
+ self.rooms[room].rect.cells().filter(|&p| self.tile(p) == Tile::Floor).collect()
+ }
+
+ /// Bare floor against the walls, in a random order.
+ fn by_wall(&mut self, room: usize) -> Vec<Pos> {
+ let mut cells: Vec<Pos> = self
+ .floor_of(room)
+ .into_iter()
+ .filter(|&p| {
+ [(0, -1), (0, 1), (-1, 0), (1, 0)]
+ .iter()
+ .any(|&(dx, dy)| self.tile(p.offset(dx, dy)) == Tile::Wall)
+ })
+ .collect();
+ cells.shuffle(&mut self.rng);
+ cells
+ }
+
+ /// Bare floor out in the room, away from the walls, in a random order.
+ fn middle(&mut self, room: usize) -> Vec<Pos> {
+ let mut cells: Vec<Pos> = self
+ .floor_of(room)
+ .into_iter()
+ .filter(|&p| p.neighbours().all(|n| self.tile(n) != Tile::Wall))
+ .collect();
+ cells.shuffle(&mut self.rng);
+ cells
+ }
+
+ fn scatter(&mut self, cells: Vec<Pos>, thing: Thing, n: usize) -> usize {
+ let mut placed = 0;
+ for p in cells {
+ if placed == n {
+ break;
+ }
+ if self.put(p, thing) {
+ placed += 1;
+ }
+ }
+ placed
+ }
+
+ fn against(&mut self, room: usize, thing: Thing, lo: usize, hi: usize) {
+ let n = self.rng.random_range(lo..=hi);
+ let cells = self.by_wall(room);
+ self.scatter(cells, thing, n);
+ }
+
+ fn out_in(&mut self, room: usize, thing: Thing, lo: usize, hi: usize) {
+ let n = self.rng.random_range(lo..=hi);
+ let cells = self.middle(room);
+ self.scatter(cells, thing, n);
+ }
+
+ fn rug(&mut self, room: usize, chance: f64) {
+ if !self.rng.random_bool(chance) {
+ return;
+ }
+ let r = self.rooms[room].rect;
+ if r.width() < 6 || r.height() < 4 {
+ return;
+ }
+ let inner = Rect::new(r.left + 2, r.top + 1, r.right - 2, r.bottom - 1);
+ for p in inner.cells() {
+ if self.tile(p) == Tile::Floor {
+ self.site.set(p, Tile::Rug);
+ }
+ }
+ }
+
+ /// A table a few cells long out in the room, chairs round it.
+ fn table(&mut self, room: usize, seats: usize) -> Vec<Pos> {
+ let cells = self.middle(room);
+ for c in cells.into_iter().take(6) {
+ let len = self.rng.random_range(1..=3);
+ if !(0..len).all(|i| self.tile(c.offset(i, 0)) == Tile::Floor) {
+ continue;
+ }
+ let mut placed = 0;
+ for i in 0..len {
+ if self.put(c.offset(i, 0), Thing::Table) {
+ placed += 1;
+ }
+ }
+ if placed == 0 {
+ continue;
+ }
+ let mut around: Vec<Pos> = (0..placed)
+ .flat_map(|i| [c.offset(i, -1), c.offset(i, 1)])
+ .chain([c.offset(-1, 0), c.offset(placed, 0)])
+ .collect();
+ around.shuffle(&mut self.rng);
+ let mut chairs = Vec::new();
+ for p in around.into_iter().take(seats.max(2)) {
+ if self.put(p, Thing::Chair) {
+ chairs.push(p);
+ }
+ }
+ return chairs;
+ }
+ Vec::new()
+ }
+
+ /// A long table down the middle of the room, chairs either side.
+ fn long_table(&mut self, room: usize) -> Vec<Pos> {
+ let r = self.rooms[room].rect;
+ let c = r.centre();
+ let mut chairs = Vec::new();
+ if r.width() < 7 || r.height() < 3 {
+ return chairs;
+ }
+ for x in r.left + 2..=r.right - 2 {
+ if self.put(Pos::new(x, c.y), Thing::Table) {
+ for dy in [-1, 1] {
+ if self.put(Pos::new(x, c.y + dy), Thing::Chair) {
+ chairs.push(Pos::new(x, c.y + dy));
+ }
+ }
+ }
+ }
+ chairs
+ }
+
+ /// Rows of something across the room, every `every` rows back from the
+ /// back wall, leaving aisles at the ends and, if `aisle`, one down the
+ /// middle.
+ fn rows(&mut self, room: usize, thing: Thing, first: i32, every: i32, aisle: bool) -> Vec<Pos> {
+ let f = self.frame(room);
+ let (len_u, len_v) = (f.len_u(), f.len_v());
+ let mid = len_u / 2;
+ let mut placed = Vec::new();
+ let mut v = first;
+ while v < len_v - 2 {
+ for u in 1..len_u - 1 {
+ if aisle && (u - mid).abs() <= 1 {
+ continue;
+ }
+ let p = f.at(u, v);
+ if self.put(p, thing) {
+ placed.push(p);
+ }
+ }
+ v += every;
+ }
+ placed
+ }
+
+ /// A runner from the front of the room to the back, down the middle.
+ fn runner(&mut self, room: usize) {
+ let f = self.frame(room);
+ let mid = f.len_u() / 2;
+ for v in 0..f.len_v() {
+ for u in [mid - 1, mid, mid + 1] {
+ let p = f.at(u, v);
+ if self.tile(p) == Tile::Floor {
+ self.site.set(p, Tile::Rug);
+ }
+ }
+ }
+ }
+
+ /// The keeper's corner at the back of the room: shelves against the
+ /// back wall, a counter in front of them, the keeper between, and the
+ /// counter turned back to the wall at either end so nobody walks round
+ /// it. Keeps clear of doorways in the back wall. Returns where the
+ /// keeper stands.
+ fn nook(&mut self, room: usize, share: f64) -> Option<Pos> {
+ let f = self.frame(room);
+ let (len_u, len_v) = (f.len_u(), f.len_v());
+ if len_v < 4 || len_u < 5 {
+ return None;
+ }
+ // Doorways in the back wall, and the side walls by the back.
+ let blocked: Vec<i32> =
+ (0..len_u).filter(|&u| self.is_door(f.at(u, -1)) || self.is_door(f.at(u, 0))).collect();
+ let side_doors = (0..3).any(|v| self.is_door(f.at(-1, v)))
+ || (0..3).any(|v| self.is_door(f.at(len_u, v)));
+ // The longest run of the back wall with no doorway within a cell.
+ let mut best = (0, -1);
+ let mut start = 0;
+ for u in 0..=len_u {
+ let clear = u < len_u && !blocked.iter().any(|&b| (b - u).abs() <= 1);
+ if !clear {
+ if u - 1 - start > best.1 - best.0 {
+ best = (start, u - 1);
+ }
+ start = u + 1;
+ }
+ }
+ let (lo, hi) = best;
+ // Room for a cap at each end that isn't against a side wall.
+ let lo = if lo == 0 && !side_doors { 0 } else { lo + 1 };
+ let hi = if hi == len_u - 1 && !side_doors { hi } else { hi - 1 };
+ let room_for = hi - lo + 1;
+ if room_for < 3 {
+ return None;
+ }
+ let want = ((f64::from(len_u) * share).round() as i32).clamp(3, room_for);
+ let a = self.rng.random_range(lo..=hi - want + 1);
+ let b = a + want - 1;
+ let mid = (a + b) / 2;
+ for u in a - 1..=b + 1 {
+ if u >= 0 && u < len_u {
+ self.site.set(f.at(u, 1), Tile::Counter);
+ }
+ }
+ for u in [a - 1, b + 1] {
+ if u >= 0 && u < len_u {
+ self.site.set(f.at(u, 0), Tile::Counter);
+ }
+ }
+ for u in a..=b {
+ self.site.set(f.at(u, 0), if u == mid { Tile::Floor } else { Tile::Shelf });
+ }
+ self.reachable = self.count_reachable();
+ Some(f.at(mid, 0))
+ }
+
+ /// A free floor cell in the room that you can walk to.
+ fn spot(&mut self, room: usize, seen: &[bool]) -> Option<Pos> {
+ let mut cells: Vec<Pos> = self.rooms[room]
+ .rect
+ .cells()
+ .filter(|&p| {
+ matches!(self.tile(p), Tile::Floor | Tile::Rug)
+ && seen[self.site.index(p)]
+ && self.site.person_at(p).is_none()
+ && p != self.start
+ && !p.neighbours().any(|n| n == self.exit)
+ })
+ .collect();
+ cells.shuffle(&mut self.rng);
+ cells.first().copied()
+ }
+
+ fn add(&mut self, pos: Pos, role: Role, stays: bool) {
+ let named = matches!(
+ role,
+ Role::Resident | Role::Ruler | Role::Priest | Role::Worker | Role::Courtier
+ );
+ let name = match role {
+ Role::Keeper(_) => self.keeper.clone(),
+ _ if named => Some(self.culture.person(&mut self.rng)),
+ _ => None,
+ };
+ let says = *words::lines(role, self.culture).choose(&mut self.rng).unwrap_or(&"...");
+ self.site.people.push(Person { pos, role, name, says, stays });
+ }
+
+ /// Up to `n` people wandering the room.
+ fn crowd(&mut self, room: usize, role: Role, n: std::ops::RangeInclusive<usize>) {
+ let n = self.rng.random_range(n);
+ let seen = self.site.reachable(self.start);
+ for _ in 0..n {
+ if let Some(p) = self.spot(room, &seen) {
+ self.add(p, role, false);
+ }
+ }
+ }
+
+ /// Up to `n` people sitting in these seats, staying put.
+ fn seat(&mut self, seats: &[Pos], role: Role, n: std::ops::RangeInclusive<usize>) {
+ let n = self.rng.random_range(n);
+ let mut seats: Vec<Pos> =
+ seats.iter().copied().filter(|&p| self.site.person_at(p).is_none()).collect();
+ seats.shuffle(&mut self.rng);
+ for p in seats.into_iter().take(n) {
+ self.add(p, role, true);
+ }
+ }
+
+ /// Puts `thing` at the middle of the back wall, and returns the cell in
+ /// front of it.
+ fn at_back(&mut self, room: usize, thing: Thing) -> Option<Pos> {
+ let f = self.frame(room);
+ let mid = f.len_u() / 2;
+ for u in [mid, mid - 1, mid + 1, mid - 2, mid + 2] {
+ let p = f.at(u, 0);
+ if self.put(p, thing) {
+ return Some(f.at(u, 1));
+ }
+ }
+ None
+ }
+
+ fn furnish(&mut self, room: usize) {
+ use Purpose::*;
+ use Thing::*;
+ let purpose = self.rooms[room].purpose;
+ let area = self.rooms[room].rect.area() as usize;
+ let culture = self.culture;
+ match purpose {
+ Living => {
+ self.against(room, Hearth, 1, 1);
+ self.table(room, 4);
+ self.against(room, Shelf, 1, 2);
+ self.against(room, Chest, 0, 1);
+ self.out_in(room, Plant, 0, 1);
+ self.rug(room, 0.3);
+ }
+ Bedroom => {
+ self.against(room, Bed, 1, 2);
+ self.against(room, Chest, 0, 1);
+ self.rug(room, 0.3);
+ }
+ Lodging => {
+ self.against(room, Bed, 1, 1);
+ self.against(room, Chest, 0, 1);
+ self.crowd(room, Role::Resident, 0..=1);
+ }
+ Kitchen => {
+ self.against(room, Stove, 1, 1);
+ self.against(room, Barrel, 1, 3);
+ self.against(room, Shelf, 0, 1);
+ self.table(room, 2);
+ if self.kind == Kind::Tenement {
+ self.crowd(room, Role::Resident, 1..=2);
+ }
+ }
+ Store => {
+ self.against(room, Crate, 2, 5);
+ self.against(room, Barrel, 1, 3);
+ }
+ Taproom => {
+ if let Some(at) = self.nook(room, 0.45) {
+ self.add(at, Role::Keeper(self.shop.unwrap_or(0)), true);
+ }
+ self.against(room, Hearth, 1, 1);
+ let mut seats = Vec::new();
+ for _ in 0..(area / 28).clamp(2, 8) {
+ seats.extend(self.table(room, 4));
+ }
+ let n = self.rng.random_range(3..=(seats.len().max(3)));
+ self.seat(&seats, Role::Patron, n..=n);
+ self.crowd(room, Role::Patron, 0..=2);
+ }
+ Parlour => {
+ let mut seats = self.table(room, 4);
+ if area > 40 {
+ seats.extend(self.table(room, 4));
+ }
+ self.seat(&seats, Role::Patron, 1..=3);
+ self.against(room, Shelf, 0, 1);
+ }
+ Cellar => {
+ self.rows(room, Barrel, 1, 2, false);
+ self.against(room, Crate, 1, 3);
+ }
+ Counter => {
+ if let Some(at) = self.nook(room, 0.75) {
+ self.add(at, Role::Keeper(self.shop.unwrap_or(0)), true);
+ }
+ match self.kind {
+ Kind::Shop(ShopKind::Doctor) => {
+ let cells = self.by_wall(room);
+ let seats: Vec<Pos> =
+ cells.into_iter().filter(|&p| self.put(p, Chair)).take(3).collect();
+ self.seat(&seats, Role::Patient, 0..=1);
+ self.out_in(room, Plant, 0, 1);
+ }
+ Kind::Shop(ShopKind::Scholar) => {
+ self.against(room, Shelf, 2, 5);
+ self.out_in(room, Lamp, 1, 1);
+ }
+ Kind::Shop(ShopKind::Broker) => self.against(room, Crate, 2, 4),
+ Kind::Shop(ShopKind::Shipyard) => {
+ self.out_in(room, Engine, 1, 2);
+ self.against(room, Crate, 1, 3);
+ }
+ Kind::Shop(ShopKind::Armoury) => self.against(room, Rack, 2, 5),
+ _ => {}
+ }
+ }
+ Ward => {
+ let n = (self.rooms[room].rect.width() / 3).clamp(2, 8) as usize;
+ self.against(room, Bed, n, n);
+ self.crowd(room, Role::Patient, 1..=3);
+ self.against(room, Chest, 0, 1);
+ }
+ Stacks => {
+ self.rows(room, Shelf, 1, 2, true);
+ self.crowd(room, Role::Reader, 0..=2);
+ }
+ Assay => {
+ self.out_in(room, Workbench, 1, 2);
+ self.against(room, Crate, 2, 4);
+ self.crowd(room, Role::Worker, 0..=1);
+ }
+ Smithy => {
+ self.against(room, Forge, 1, 1);
+ self.out_in(room, Anvil, 1, 2);
+ self.against(room, Rack, 1, 2);
+ self.out_in(room, Workbench, 0, 1);
+ self.crowd(room, Role::Worker, 0..=1);
+ }
+ Yard => {
+ self.out_in(room, Engine, 2, 4);
+ self.against(room, Crate, 2, 5);
+ self.out_in(room, Anvil, 0, 1);
+ self.crowd(room, Role::Worker, 1..=3);
+ }
+ Hall => {
+ self.runner(room);
+ self.against(room, Statue, 2, 4);
+ self.against(room, Plant, 1, 3);
+ self.against(room, Lamp, 1, 2);
+ }
+ Dining => {
+ let seats = self.long_table(room);
+ self.against(room, Shelf, 0, 1);
+ if self.rng.random_bool(0.4) {
+ self.seat(&seats, Role::Resident, 1..=1);
+ }
+ self.crowd(room, Role::Servant, 0..=1);
+ }
+ Library => {
+ self.against(room, Shelf, 4, 12);
+ let seats = self.table(room, 2);
+ self.seat(&seats, Role::Reader, 0..=1);
+ }
+ Bedchamber => {
+ self.against(room, Bed, 1, 1);
+ self.against(room, Chest, 1, 1);
+ self.against(room, Lamp, 0, 1);
+ self.out_in(room, Plant, 0, 1);
+ self.rug(room, 0.7);
+ }
+ Gallery => {
+ self.against(room, Statue, 3, 6);
+ self.out_in(room, Plant, 1, 2);
+ }
+ Purpose::Throne => {
+ self.runner(room);
+ let f = self.frame(room);
+ let mid = f.len_u() / 2;
+ // The throne at the end of the runner, lamps beside it.
+ let throne = f.at(mid, 0);
+ if self.tile(throne) == Tile::Rug {
+ self.site.set(throne, Tile::Floor);
+ }
+ if self.put(throne, Thing::Throne) {
+ self.add(throne, Role::Ruler, true);
+ }
+ for u in [mid - 2, mid + 2] {
+ self.put(f.at(u, 0), Lamp);
+ }
+ // Pillars down both sides, guards along the runner.
+ let mut v = 2;
+ while v < f.len_v() - 2 {
+ for u in [mid - 4, mid + 4] {
+ if u > 0 && u < f.len_u() - 1 {
+ self.put(f.at(u, v), Pillar);
+ }
+ }
+ v += 3;
+ }
+ let mut guards = 0;
+ for v in (2..f.len_v() - 1).step_by(4) {
+ for u in [mid - 2, mid + 2] {
+ let p = f.at(u, v);
+ if guards < 6 && self.tile(p).walkable() && self.site.person_at(p).is_none()
+ {
+ self.add(p, Role::Guard, true);
+ guards += 1;
+ }
+ }
+ }
+ self.crowd(room, Role::Courtier, 2..=4);
+ }
+ Council => {
+ let seats = self.long_table(room);
+ self.seat(&seats, Role::Courtier, 1..=4);
+ self.against(room, Shelf, 0, 2);
+ }
+ Treasury => {
+ self.against(room, Chest, 4, 9);
+ self.crowd(room, Role::Guard, 1..=1);
+ }
+ Nave => {
+ let altar = self.at_back(room, Altar);
+ let f = self.frame(room);
+ let mid = f.len_u() / 2;
+ for u in [mid - 2, mid + 2] {
+ self.put(f.at(u, 0), Lamp);
+ }
+ if let Some(at) = altar {
+ self.add(at, Role::Priest, true);
+ }
+ let pews = self.rows(room, Pew, 3, 2, true);
+ let n = self.rng.random_range(2..=(pews.len() / 3).max(3));
+ self.seat(&pews, Role::Worshipper, n..=n);
+ self.crowd(room, Role::Pilgrim, 0..=2);
+ }
+ Vestry => {
+ self.against(room, Shelf, 1, 2);
+ self.against(room, Chest, 1, 1);
+ self.table(room, 1);
+ }
+ Cell => {
+ self.against(room, Bed, 1, 1);
+ self.against(room, Chest, 0, 1);
+ if self.rng.random_bool(0.5) {
+ self.crowd(room, Role::Priest, 1..=1);
+ }
+ }
+ Shrine => {
+ if let Some(at) = self.at_back(room, Altar) {
+ self.add(at, Role::Priest, true);
+ }
+ let f = self.frame(room);
+ let mid = f.len_u() / 2;
+ for u in [mid - 2, mid + 2] {
+ self.put(f.at(u, 0), Lamp);
+ }
+ let benches = self.rows(room, Bench, 3, 3, true);
+ self.seat(&benches, Role::Worshipper, 0..=2);
+ self.crowd(room, Role::Pilgrim, 0..=2);
+ }
+ Workroom => {
+ match culture {
+ Culture::Cant => {
+ self.against(room, Forge, 1, 1);
+ self.out_in(room, Anvil, 1, 2);
+ self.against(room, Rack, 0, 1);
+ }
+ Culture::Qaari => {
+ self.out_in(room, Loom, 1, 2);
+ self.against(room, Barrel, 1, 2);
+ }
+ Culture::Houses => {
+ self.out_in(room, Workbench, 1, 3);
+ self.against(room, Shelf, 1, 2);
+ }
+ Culture::Drift => {
+ self.out_in(room, Engine, 1, 1);
+ self.out_in(room, Workbench, 1, 2);
+ }
+ }
+ self.against(room, Workbench, 1, 2);
+ self.against(room, Crate, 0, 2);
+ self.crowd(room, Role::Worker, 1..=3);
+ }
+ Storehouse => {
+ let goods = if self.rng.random_bool(0.6) { Crate } else { Barrel };
+ self.rows(room, goods, 1, 2, true);
+ self.crowd(room, Role::Watchman, 1..=1);
+ }
+ Office => {
+ let seats = self.table(room, 1);
+ self.against(room, Shelf, 1, 1);
+ self.seat(&seats, Role::Worker, 0..=1);
+ }
+ Changing => {
+ self.against(room, Bench, 2, 5);
+ self.against(room, Chest, 1, 2);
+ self.crowd(room, Role::Worker, 1..=1);
+ }
+ Baths => {
+ let r = self.rooms[room].rect;
+ let pool = Rect::new(r.left + 3, r.top + 2, r.right - 3, r.bottom - 2);
+ if pool.width() >= 2 && pool.height() >= 1 {
+ for p in pool.cells() {
+ self.put(p, Bath);
+ }
+ }
+ self.crowd(room, Role::Bather, 2..=5);
+ }
+ Steam => {
+ self.against(room, Stove, 1, 1);
+ let cells = self.by_wall(room);
+ let seats: Vec<Pos> =
+ cells.into_iter().filter(|&p| self.put(p, Bench)).take(5).collect();
+ self.seat(&seats, Role::Bather, 1..=3);
+ }
+ Guardroom => {
+ let seats = self.table(room, 4);
+ self.seat(&seats, Role::Guard, 1..=3);
+ self.against(room, Rack, 1, 3);
+ self.crowd(room, Role::Guard, 0..=2);
+ }
+ Bunks => self.against(room, Bed, 3, 8),
+ }
+ }
+
+ /// The people who live in a house or a great house.
+ fn residents(&mut self) {
+ let (role, n, servants) = match self.kind {
+ Kind::House => (Role::Resident, self.rng.random_range(1..=4), 0),
+ Kind::Manor => {
+ (Role::Resident, self.rng.random_range(1..=2), self.rng.random_range(1..=3))
+ }
+ Kind::Palace => (Role::Servant, self.rng.random_range(1..=3), 0),
+ Kind::Workshop => (Role::Worker, self.rng.random_range(0..=1), 0),
+ _ => return,
+ };
+ let rooms = self.rooms.len();
+ for (role, n) in [(role, n), (Role::Servant, servants)] {
+ for _ in 0..n {
+ let room = self.rng.random_range(0..rooms);
+ self.crowd(room, role, 1..=1);
+ }
+ }
+ }
+}
diff --git a/src/town/mod.rs b/src/town/mod.rs
new file mode 100644
index 0000000..fd17b20
--- /dev/null
+++ b/src/town/mod.rs
@@ -0,0 +1,850 @@
+//! Capital cities: where you walk when you land somewhere people live.
+//!
+//! A capital comes at two scales. Outside is the whole city, seen from
+//! high up: a wall around it, a landing field beyond the west gate, avenues
+//! to a market square, and streets and alleys between blocks of buildings,
+//! each one a small roof with a door. Bump a door and you go in, and the
+//! building becomes a map of its own at a much closer scale: rooms,
+//! furniture, and the people who live or work there.
+//!
+//! - `plan` lays out the city: walls, gates, quarters, streets, landmarks
+//! and the buildings, and which of them are shops.
+//! - `interior` builds what's inside a building when you go in.
+//! - `words` holds what everyone says, and what things are called.
+//!
+//! A city is generated from its place, so it's the same city every time you
+//! land, and the more people live on a world the bigger its capital is.
+//! Interiors come from the building, so a house has the same rooms each
+//! visit too.
+//!
+//! The world it stands on shapes it as well (see `Land`): the ground, what
+//! grows in its parks and what stands outside its walls, how many parks
+//! there are, and, on an ocean world, the sea all round. The weather on the
+//! day you land only changes how it looks.
+
+mod interior;
+mod plan;
+pub mod words;
+
+pub use interior::{Interior, Purpose, Thing};
+
+use rand::seq::IndexedRandom;
+
+use crate::dungeon::Pos;
+use crate::names::Culture;
+use crate::seed;
+use crate::space::survey::{self, FREEZING, Temperature};
+use crate::space::system::{PlaceKind, System, Terrain};
+
+/// Columns and rows your ship takes up on the pad.
+pub const SHIP_SIZE: (i32, i32) = (5, 3);
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Tile {
+ /// Outside the walls.
+ Ground,
+ /// The landing field.
+ Pad,
+ /// Your ship, standing on the pad.
+ Ship,
+ CityWall,
+ Gate,
+ Street,
+ /// Paving: the market square, plazas, the forecourts of great buildings.
+ Square,
+ /// Open ground inside the walls: yards, courtyards, parks.
+ Garden,
+ /// A pond.
+ Water,
+ /// The open sea round a city on an ocean world.
+ Sea,
+ /// Pipes, scrap: in the way, just for looks. Cultures that don't leave
+ /// things lying about plant trees instead.
+ Decor,
+ /// A tree, or whatever grows instead on this world.
+ Tree,
+ /// Something outside the walls: a boulder, a cactus, an ice spire, a
+ /// vent. Each world has two sorts (see `Town::wild_sort`).
+ Wild,
+ /// The well, fountain or furnace in the middle of the market square.
+ Centrepiece,
+ /// A market stall's awning.
+ Stall,
+ Statue,
+ Well,
+ Lamp,
+ /// A building, seen from the street. Which one is in `Town::building_at`.
+ Roof,
+ /// A building's street door. Bump it to go in, unless it's barred.
+ Door {
+ open: bool,
+ },
+ /// A building's walls, from inside.
+ Wall,
+ Floor,
+ /// The way between two rooms.
+ Doorway,
+ /// The door back out to the street.
+ Exit,
+ /// A shop's counter, with the keeper behind it.
+ Counter,
+ /// The shop's wares, behind the counter.
+ Shelf,
+ Rug,
+ Furniture(Thing),
+}
+
+impl Tile {
+ pub fn walkable(self) -> bool {
+ match self {
+ Tile::Ground
+ | Tile::Pad
+ | Tile::Gate
+ | Tile::Street
+ | Tile::Square
+ | Tile::Garden
+ | Tile::Floor
+ | Tile::Doorway
+ | Tile::Rug => true,
+ Tile::Furniture(thing) => thing.walkable(),
+ _ => false,
+ }
+ }
+
+ /// Open air inside the walls, where townsfolk wander.
+ pub fn outdoors(self) -> bool {
+ matches!(self, Tile::Gate | Tile::Street | Tile::Square | Tile::Garden)
+ }
+
+ /// A street or square: what a building's door has to open onto.
+ fn paved(self) -> bool {
+ matches!(self, Tile::Street | Tile::Square)
+ }
+}
+
+/// A rectangle of cells, edges included.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+struct Rect {
+ left: i32,
+ top: i32,
+ right: i32,
+ bottom: i32,
+}
+
+impl Rect {
+ const fn new(left: i32, top: i32, right: i32, bottom: i32) -> Rect {
+ Rect { left, top, right, bottom }
+ }
+
+ fn width(self) -> i32 {
+ self.right - self.left + 1
+ }
+
+ fn height(self) -> i32 {
+ self.bottom - self.top + 1
+ }
+
+ fn area(self) -> i32 {
+ self.width().max(0) * self.height().max(0)
+ }
+
+ fn centre(self) -> Pos {
+ Pos::new((self.left + self.right) / 2, (self.top + self.bottom) / 2)
+ }
+
+ fn contains(self, p: Pos) -> bool {
+ p.x >= self.left && p.x <= self.right && p.y >= self.top && p.y <= self.bottom
+ }
+
+ fn cells(self) -> impl Iterator<Item = Pos> {
+ (self.top..=self.bottom)
+ .flat_map(move |y| (self.left..=self.right).map(move |x| Pos::new(x, y)))
+ }
+}
+
+/// One of the four ways a door can face.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Side {
+ North,
+ South,
+ West,
+ East,
+}
+
+impl Side {
+ pub fn step(self) -> (i32, i32) {
+ match self {
+ Side::North => (0, -1),
+ Side::South => (0, 1),
+ Side::West => (-1, 0),
+ Side::East => (1, 0),
+ }
+ }
+
+ fn opposite(self) -> Side {
+ match self {
+ Side::North => Side::South,
+ Side::South => Side::North,
+ Side::West => Side::East,
+ Side::East => Side::West,
+ }
+ }
+}
+
+/// A map you can walk around, and the people on it. The city's streets are
+/// one; each building's inside is another.
+pub struct Site {
+ pub width: i32,
+ pub height: i32,
+ tiles: Vec<Tile>,
+ pub people: Vec<Person>,
+ /// What's off the edge of the map.
+ edge: Tile,
+ /// Whether people here keep to the open air (the streets) or to the
+ /// floors (indoors).
+ streets: bool,
+}
+
+impl Site {
+ fn new(width: i32, height: i32, fill: Tile, edge: Tile, streets: bool) -> Site {
+ Site {
+ width,
+ height,
+ tiles: vec![fill; (width * height) as usize],
+ people: Vec::new(),
+ edge,
+ streets,
+ }
+ }
+
+ pub fn contains(&self, p: Pos) -> bool {
+ p.x >= 0 && p.y >= 0 && p.x < self.width && p.y < self.height
+ }
+
+ fn index(&self, p: Pos) -> usize {
+ (p.y * self.width + p.x) as usize
+ }
+
+ /// The tile at `p`; off the map is the edge, so nothing walks off it.
+ pub fn tile(&self, p: Pos) -> Tile {
+ if self.contains(p) { self.tiles[self.index(p)] } else { self.edge }
+ }
+
+ fn set(&mut self, p: Pos, tile: Tile) {
+ let i = self.index(p);
+ self.tiles[i] = tile;
+ }
+
+ fn fill(&mut self, r: Rect, tile: Tile) {
+ for p in r.cells() {
+ if self.contains(p) {
+ self.set(p, tile);
+ }
+ }
+ }
+
+ pub fn positions(&self) -> impl Iterator<Item = Pos> + use<> {
+ Rect::new(0, 0, self.width - 1, self.height - 1).cells()
+ }
+
+ pub fn person_at(&self, p: Pos) -> Option<usize> {
+ self.people.iter().position(|person| person.pos == p)
+ }
+
+ /// Whether someone could step onto `p` right now.
+ pub fn free(&self, p: Pos) -> bool {
+ self.tile(p).walkable() && self.person_at(p).is_none()
+ }
+
+ /// Where people here may wander.
+ fn roams(&self, tile: Tile) -> bool {
+ if self.streets { tile.outdoors() } else { tile.walkable() }
+ }
+
+ /// Steps someone who's wandering aside onto `to`, where you were, as you
+ /// walk through them, if they'd go there. Otherwise they stay where
+ /// they are, and you share the cell until they wander off.
+ pub fn make_way(&mut self, who: usize, to: Pos) {
+ if !self.people[who].stays && self.roams(self.tile(to)) && self.free(to) {
+ self.people[who].pos = to;
+ }
+ }
+
+ /// Moves someone a step at random, if they're the wandering kind,
+ /// staying off `avoid` (where you're standing).
+ pub fn wander(&mut self, who: usize, rng: &mut seed::Rng, avoid: Pos) {
+ if self.people[who].stays {
+ return;
+ }
+ let from = self.people[who].pos;
+ let steps: Vec<Pos> = from
+ .neighbours()
+ .filter(|&p| p != avoid && self.roams(self.tile(p)) && self.free(p))
+ .collect();
+ if let Some(&to) = steps.choose(rng) {
+ self.people[who].pos = to;
+ }
+ }
+
+ /// Every cell you can walk to from `from`.
+ pub fn reachable(&self, from: Pos) -> Vec<bool> {
+ let mut seen = vec![false; self.tiles.len()];
+ if !self.contains(from) {
+ return seen;
+ }
+ let mut queue = std::collections::VecDeque::from([from]);
+ seen[self.index(from)] = true;
+ while let Some(p) = queue.pop_front() {
+ for n in p.neighbours() {
+ if self.contains(n) && self.tile(n).walkable() && !seen[self.index(n)] {
+ seen[self.index(n)] = true;
+ queue.push_back(n);
+ }
+ }
+ }
+ seen
+ }
+}
+
+/// What someone in a city is doing there, which decides what they say and
+/// whether they move about.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Role {
+ Passerby,
+ Child,
+ Porter,
+ Pilgrim,
+ Stallholder,
+ Guard,
+ /// The keeper of the shop with this index in `Town::shops`.
+ Keeper(usize),
+ Resident,
+ Servant,
+ Ruler,
+ Courtier,
+ Priest,
+ Worshipper,
+ Worker,
+ Patron,
+ Patient,
+ Bather,
+ Watchman,
+ Reader,
+}
+
+/// Someone in the streets or indoors.
+pub struct Person {
+ pub pos: Pos,
+ pub role: Role,
+ /// Residents, keepers and the like have names; passers-by don't.
+ pub name: Option<String>,
+ pub says: &'static str,
+ /// Stallholders, keepers, worshippers in their pews: they stay put.
+ pub stays: bool,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ShopKind {
+ Broker,
+ Doctor,
+ Scholar,
+ Tavern,
+ Shipyard,
+ /// Weapons, bought and sold.
+ Armoury,
+}
+
+impl ShopKind {
+ pub const ALL: [ShopKind; 6] = [
+ ShopKind::Broker,
+ ShopKind::Doctor,
+ ShopKind::Scholar,
+ ShopKind::Tavern,
+ ShopKind::Shipyard,
+ ShopKind::Armoury,
+ ];
+
+ /// What's painted on the roof.
+ pub fn sign(self) -> &'static str {
+ match self {
+ ShopKind::Broker => "ORE",
+ ShopKind::Doctor => "DOCTOR",
+ ShopKind::Scholar => "SCHOLAR",
+ ShopKind::Tavern => "TAVERN",
+ ShopKind::Shipyard => "SHIPYARD",
+ ShopKind::Armoury => "ARMS",
+ }
+ }
+
+ /// "the doctor", for giving directions.
+ pub fn trade(self) -> &'static str {
+ match self {
+ ShopKind::Broker => "an ore broker",
+ ShopKind::Doctor => "a doctor",
+ ShopKind::Scholar => "a scholar",
+ ShopKind::Tavern => "a tavern",
+ ShopKind::Shipyard => "a shipyard",
+ ShopKind::Armoury => "an armourer",
+ }
+ }
+}
+
+/// What a building is for.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Kind {
+ Shop(ShopKind),
+ House,
+ /// Lots of small rooms, lots of people.
+ Tenement,
+ /// A great house, in the grand quarter.
+ Manor,
+ /// Where the city's ruler sits. One to a city.
+ Palace,
+ /// The city's great temple. One to a city.
+ Temple,
+ Shrine,
+ Workshop,
+ Warehouse,
+ Bathhouse,
+ Guardhouse,
+}
+
+/// One building, as it stands in the street.
+pub struct Building {
+ pub kind: Kind,
+ /// Its footprint on the city map.
+ pub left: i32,
+ pub top: i32,
+ pub right: i32,
+ pub bottom: i32,
+ /// On the footprint's edge, facing the street.
+ pub door: Pos,
+ pub facing: Side,
+ pub barred: bool,
+ /// Index into `Town::shops`, if it's a shop.
+ pub shop: Option<usize>,
+ /// "the Sand Archive", "a house", "House Vastre".
+ pub name: String,
+}
+
+impl Building {
+ /// The street cell in front of the door.
+ pub fn front(&self) -> Pos {
+ let (dx, dy) = self.facing.step();
+ self.door.offset(dx, dy)
+ }
+
+ pub fn centre(&self) -> Pos {
+ Pos::new((self.left + self.right) / 2, (self.top + self.bottom) / 2)
+ }
+
+ pub fn width(&self) -> i32 {
+ self.right - self.left + 1
+ }
+
+ pub fn height(&self) -> i32 {
+ self.bottom - self.top + 1
+ }
+
+ #[cfg(test)]
+ fn rect(&self) -> Rect {
+ Rect::new(self.left, self.top, self.right, self.bottom)
+ }
+}
+
+pub struct Shop {
+ pub kind: ShopKind,
+ /// "the Sand Archive".
+ pub name: String,
+ pub keeper: String,
+ pub building: usize,
+}
+
+/// The kinds of neighbourhood a city is made of. They decide how big the
+/// blocks and buildings are, and what the buildings are.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum QuarterKind {
+ /// Around the square.
+ Market,
+ /// Inside the west gate, by the landing field.
+ Gate,
+ Homes,
+ Poor,
+ /// Where the palace and the great houses are.
+ Grand,
+ /// Around the temple.
+ Holy,
+ Works,
+ Gardens,
+}
+
+pub struct Quarter {
+ pub kind: QuarterKind,
+ /// "the Spice Souk".
+ pub name: String,
+ /// Where it's centred, for the map.
+ pub centre: Pos,
+}
+
+pub struct Town {
+ /// "Erg Khaseem".
+ pub name: String,
+ pub culture: Culture,
+ /// The world it's built on, and the weather today.
+ pub land: Land,
+ seed: u64,
+ /// The streets, and the people in them.
+ pub site: Site,
+ /// Which building each cell is part of, `NONE` for none.
+ owner: Vec<u16>,
+ /// Which quarter each cell inside the walls is in, `NONE` outside.
+ quarter_of: Vec<u8>,
+ pub quarters: Vec<Quarter>,
+ pub buildings: Vec<Building>,
+ pub shops: Vec<Shop>,
+ /// The top left of your ship, which is `SHIP_SIZE` big.
+ pub ship: Pos,
+ /// Where you step off your ship.
+ pub start: Pos,
+ /// The middle of the market square.
+ pub square: Pos,
+ /// The middle of each gate, the west gate first.
+ pub gates: Vec<Pos>,
+}
+
+const NO_OWNER: u16 = u16::MAX;
+const NO_QUARTER: u8 = u8::MAX;
+
+impl Town {
+ pub fn width(&self) -> i32 {
+ self.site.width
+ }
+
+ pub fn height(&self) -> i32 {
+ self.site.height
+ }
+
+ pub fn tile(&self, p: Pos) -> Tile {
+ self.site.tile(p)
+ }
+
+ /// Which of its world's two sorts of `Tile::Wild` stands at `p`.
+ pub fn wild_sort(&self, p: Pos) -> usize {
+ let h = seed::derive(self.seed, "wild", (p.x as u64) << 32 | p.y as u64);
+ usize::from(h.is_multiple_of(3))
+ }
+
+ /// How much redder, greener and bluer this city's ground is than
+ /// others on the same kind of world, from 0.92 to 1.08 times, so no two
+ /// look quite alike.
+ pub fn tint(&self) -> [f64; 3] {
+ std::array::from_fn(|i| {
+ let h = seed::derive(self.seed, "tint", i as u64);
+ 0.92 + 0.16 * (h >> 11) as f64 / (1u64 << 53) as f64
+ })
+ }
+
+ /// The building whose footprint `p` is in, door included.
+ pub fn building_at(&self, p: Pos) -> Option<usize> {
+ if !self.site.contains(p) {
+ return None;
+ }
+ let owner = self.owner[self.site.index(p)];
+ (owner != NO_OWNER).then_some(usize::from(owner))
+ }
+
+ /// The building whose street door is at `p`.
+ pub fn door_at(&self, p: Pos) -> Option<usize> {
+ self.building_at(p).filter(|&b| self.buildings[b].door == p)
+ }
+
+ pub fn quarter_at(&self, p: Pos) -> Option<&Quarter> {
+ if !self.site.contains(p) {
+ return None;
+ }
+ self.quarters.get(usize::from(self.quarter_of[self.site.index(p)]))
+ }
+
+ /// Goes into a building: builds its inside, the same every time.
+ pub fn enter(&self, building: usize) -> Interior {
+ interior::build(self, building)
+ }
+
+ /// The shop of this kind nearest to `from`.
+ pub fn nearest_shop(&self, kind: ShopKind, from: Pos) -> Option<&Shop> {
+ self.shops
+ .iter()
+ .filter(|s| s.kind == kind)
+ .min_by_key(|s| distance2(self.buildings[s.building].door, from))
+ }
+}
+
+/// Squared distance as it looks on screen, where rows are twice as tall as
+/// columns are wide.
+pub fn distance2(a: Pos, b: Pos) -> i32 {
+ (a.x - b.x).pow(2) + (2 * (a.y - b.y)).pow(2)
+}
+
+/// The seed for the capital of `place` in a system.
+pub fn seed_for(system_seed: u64, place: usize) -> u64 {
+ seed::derive(system_seed, "town", place as u64)
+}
+
+/// What the capital `generate` would build is called, without building it.
+pub fn name(seed: u64, culture: Culture) -> String {
+ culture.place(&mut seed::rng(seed))
+}
+
+/// How big a capital is, from 0 to 1, for a world of this many people.
+pub fn size_for(population: Option<u64>) -> f64 {
+ let people = population.unwrap_or(10_000_000).max(1) as f64;
+ ((people.log10() - 5.3) / 4.3).clamp(0.0, 1.0)
+}
+
+/// Builds the capital for `seed`, `size` big (see `size_for`), on `land`.
+/// Only the land's terrain changes the plan: the weather doesn't move
+/// anything.
+pub fn generate(seed: u64, culture: Culture, size: f64, land: Land) -> Town {
+ let mut rng = seed::rng(seed);
+ // The name comes first, so `name` can find it alone.
+ let name = culture.place(&mut rng);
+ plan::build(name, culture, land, seed, size.clamp(0.0, 1.0), &mut rng)
+}
+
+/// What the world a city stands on does to it: what the ground is, and
+/// what the weather's doing to it today.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Land {
+ pub terrain: Terrain,
+ /// From 0 to 1: how hard it's frozen, on a world that isn't always.
+ pub frost: f64,
+ /// From 0 to 1: how parched the gardens are.
+ pub drought: f64,
+}
+
+/// What's in a city's ponds today.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Pond {
+ Water,
+ Ice,
+ /// Dried to cracked mud.
+ Dry,
+ Lava,
+}
+
+impl Land {
+ /// `terrain`, on a day `temperature` warm. Only worlds with air and
+ /// water see frost come and go and gardens wither: frost from 4°C
+ /// down, white by -10°C; drought from 30°C up, worst by 50°C.
+ pub fn new(terrain: Terrain, temperature: Option<Temperature>) -> Land {
+ let seasons = matches!(terrain, Terrain::Desert | Terrain::Ocean | Terrain::Jungle);
+ let c = temperature.map_or(15.0, |t| t.now - FREEZING);
+ let (frost, drought) = if seasons {
+ (((4.0 - c) / 14.0).clamp(0.0, 1.0), ((c - 30.0) / 20.0).clamp(0.0, 1.0))
+ } else {
+ (0.0, 0.0)
+ };
+ Land { terrain, frost, drought }
+ }
+
+ /// The land under the capital of `place` on `day`, if it's a planet or
+ /// a moon.
+ pub fn of(s: &System, place: usize, day: f64) -> Option<Land> {
+ let kind = match s.places[place].kind {
+ PlaceKind::Planet(p) => s.planets[p].kind,
+ PlaceKind::Moon(p, m) => s.planets[p].moons[m].kind,
+ PlaceKind::Belt | PlaceKind::Station(_) => return None,
+ };
+ Some(Land::new(kind.terrain, survey::survey(s, place, day).temperature))
+ }
+
+ pub fn pond(self) -> Pond {
+ match self.terrain {
+ Terrain::Forge => Pond::Lava,
+ Terrain::Ice => Pond::Ice,
+ Terrain::Barren | Terrain::Clouds => Pond::Dry,
+ _ if self.frost > 0.6 => Pond::Ice,
+ _ if self.drought > 0.8 => Pond::Dry,
+ _ => Pond::Water,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const TERRAINS: [Terrain; 6] = [
+ Terrain::Desert,
+ Terrain::Ice,
+ Terrain::Ocean,
+ Terrain::Jungle,
+ Terrain::Barren,
+ Terrain::Forge,
+ ];
+
+ fn land(terrain: Terrain) -> Land {
+ Land::new(terrain, None)
+ }
+
+ /// A mild day on a jungle world.
+ fn green() -> Land {
+ land(Terrain::Jungle)
+ }
+
+ fn towns() -> impl Iterator<Item = Town> {
+ (0..12).flat_map(|seed| {
+ Culture::ALL.into_iter().map(move |c| {
+ let terrain = TERRAINS[(seed as usize + c as usize) % TERRAINS.len()];
+ generate(seed, c, (seed % 4) as f64 / 3.0, land(terrain))
+ })
+ })
+ }
+
+ #[test]
+ fn a_capitals_name_is_known_before_its_built() {
+ for seed in 0..40 {
+ for culture in Culture::ALL {
+ assert_eq!(name(seed, culture), generate(seed, culture, 0.5, green()).name);
+ }
+ }
+ }
+
+ #[test]
+ fn the_same_seed_builds_the_same_city() {
+ let a = generate(7, Culture::Houses, 0.6, green());
+ let b = generate(7, Culture::Houses, 0.6, green());
+ assert_eq!(a.site.tiles, b.site.tiles);
+ assert_eq!(a.buildings.len(), b.buildings.len());
+ }
+
+ #[test]
+ fn the_weather_changes_how_a_city_looks_not_where_anything_is() {
+ let day = |now: f64| Temperature { now: FREEZING + now, coldest: 0.0, hottest: 0.0 };
+ let cold = Land::new(Terrain::Ocean, Some(day(-12.0)));
+ let hot = Land::new(Terrain::Ocean, Some(day(48.0)));
+ assert_eq!(cold.pond(), Pond::Ice);
+ assert_eq!(hot.pond(), Pond::Dry);
+ assert_eq!(Land::new(Terrain::Ocean, Some(day(15.0))).pond(), Pond::Water);
+ let a = generate(4, Culture::Qaari, 0.5, cold);
+ let b = generate(4, Culture::Qaari, 0.5, hot);
+ assert_eq!(a.site.tiles, b.site.tiles);
+ // Ice and forge worlds have no seasons to speak of.
+ assert_eq!(Land::new(Terrain::Ice, Some(day(-180.0))).frost, 0.0);
+ assert_eq!(Land::new(Terrain::Forge, Some(day(300.0))).drought, 0.0);
+ }
+
+ #[test]
+ fn the_world_shapes_the_city() {
+ let count =
+ |t: &Town, tile: Tile| t.site.positions().filter(|&p| t.tile(p) == tile).count();
+ let mut trees = [0; 6];
+ let mut wild = [0; 6];
+ let mut ponds = [0; 6];
+ for seed in 0..6 {
+ for (i, &terrain) in TERRAINS.iter().enumerate() {
+ let t = generate(seed, Culture::Houses, 0.5, land(terrain));
+ trees[i] += count(&t, Tile::Tree);
+ wild[i] += count(&t, Tile::Wild);
+ ponds[i] += count(&t, Tile::Water);
+ let sea = count(&t, Tile::Sea);
+ if terrain == Terrain::Ocean {
+ assert!(sea > count(&t, Tile::Ground), "{}: mostly sea outside", t.name);
+ // The landing field is on dry land, and the road from it.
+ assert!(t.site.reachable(t.start)[t.site.index(t.gates[0])]);
+ } else {
+ assert_eq!(sea, 0);
+ }
+ }
+ }
+ let [desert, ice, _, jungle, barren, _] = [0, 1, 2, 3, 4, 5];
+ assert!(trees[jungle] > 2 * trees[desert], "jungle {trees:?}");
+ assert!(trees[ice] < trees[jungle]);
+ assert!(wild[jungle] > 3 * wild[desert], "jungle {wild:?}");
+ assert_eq!(ponds[barren], 0, "nothing pools on airless rock");
+ assert!(ponds[desert] < ponds[jungle], "{ponds:?}");
+ }
+
+ #[test]
+ fn no_two_cities_are_quite_the_same_colour() {
+ let a = generate(1, Culture::Drift, 0.3, green()).tint();
+ let b = generate(2, Culture::Drift, 0.3, green()).tint();
+ assert_ne!(a, b);
+ assert!(a.iter().chain(&b).all(|k| (0.92..=1.08).contains(k)));
+ }
+
+ #[test]
+ fn bigger_worlds_have_bigger_capitals() {
+ let small = generate(3, Culture::Qaari, size_for(Some(300_000)), green());
+ let big = generate(3, Culture::Qaari, size_for(Some(3_000_000_000)), green());
+ let area = |t: &Town| t.width() * t.height();
+ assert!(area(&big) * 2 > 5 * area(&small));
+ assert!(big.buildings.len() > 2 * small.buildings.len());
+ assert!(small.buildings.len() > 60, "even a small capital is a city");
+ }
+
+ #[test]
+ fn every_door_opens_onto_a_street_you_can_reach() {
+ for t in towns() {
+ let seen = t.site.reachable(t.start);
+ for (i, b) in t.buildings.iter().enumerate() {
+ assert_eq!(t.building_at(b.door), Some(i));
+ assert!(t.tile(b.front()).paved(), "{}: door of {} opens onto nothing", t.name, i);
+ assert!(seen[t.site.index(b.front())], "{}: can't reach {}", t.name, b.name);
+ assert_eq!(t.tile(b.door), Tile::Door { open: !b.barred });
+ for p in b.rect().cells() {
+ assert_eq!(t.building_at(p), Some(i), "buildings don't overlap");
+ }
+ }
+ for p in &t.site.people {
+ assert!(t.tile(p.pos).walkable());
+ assert!(seen[t.site.index(p.pos)], "{}: someone's stuck at {:?}", t.name, p.pos);
+ }
+ }
+ }
+
+ #[test]
+ fn every_city_has_every_shop_and_a_palace_and_temple() {
+ for t in towns() {
+ for kind in ShopKind::ALL {
+ let n = t.shops.iter().filter(|s| s.kind == kind).count();
+ if kind == ShopKind::Tavern { assert!(n >= 1) } else { assert_eq!(n, 1) }
+ }
+ for (i, shop) in t.shops.iter().enumerate() {
+ let b = &t.buildings[shop.building];
+ assert_eq!(b.shop, Some(i));
+ assert!(!b.barred);
+ assert!(
+ b.width() >= shop.kind.sign().len() as i32 + 2,
+ "{}: the sign fits",
+ t.name
+ );
+ }
+ for kind in [Kind::Palace, Kind::Temple] {
+ let n = t.buildings.iter().filter(|b| b.kind == kind).count();
+ assert_eq!(n, 1, "{}: {kind:?}", t.name);
+ }
+ let kinds: std::collections::HashSet<_> =
+ t.buildings.iter().map(|b| std::mem::discriminant(&b.kind)).collect();
+ assert!(kinds.len() >= 8, "{}: a city has all sorts of buildings", t.name);
+ }
+ }
+
+ #[test]
+ fn passers_by_stay_outdoors() {
+ let mut t = generate(5, Culture::Drift, 0.4, green());
+ let mut rng = seed::rng(1);
+ for _ in 0..300 {
+ for who in 0..t.site.people.len() {
+ t.site.wander(who, &mut rng, t.start);
+ }
+ }
+ for p in &t.site.people {
+ assert!(t.tile(p.pos).outdoors() || p.stays, "{:?} wandered off the street", p.pos);
+ }
+ let mut places: Vec<Pos> = t.site.people.iter().map(|p| p.pos).collect();
+ places.sort_by_key(|p| (p.x, p.y));
+ places.dedup();
+ assert_eq!(places.len(), t.site.people.len(), "no two people in one place");
+ }
+}
diff --git a/src/town/plan.rs b/src/town/plan.rs
new file mode 100644
index 0000000..90f5e32
--- /dev/null
+++ b/src/town/plan.rs
@@ -0,0 +1,1230 @@
+//! Laying out a capital.
+//!
+//! The wall is a rounded, slightly lumpy oval. A landing field lies outside
+//! its west gate, and one to three more gates open on the other sides. A
+//! road runs inside the wall all the way round, and avenues run from each
+//! gate to the market square in the middle.
+//!
+//! The city is shared out into quarters around points scattered inside the
+//! wall: a market around the square, a gate quarter by the landing field,
+//! then a grand quarter, a holy one, homes, a poor quarter, works, gardens.
+//! Then it's cut into blocks, from the outside in: each block is split in
+//! two by a street until it's small enough for its quarter, so streets meet
+//! at T-junctions and never quite line up, the way old cities grow. Poor
+//! quarters end up with small blocks and alleys, grand ones with big blocks
+//! and wide streets.
+//!
+//! A few blocks are kept whole: one for the palace, one for the temple,
+//! parks and plazas. Every other block gets buildings packed along the
+//! streets around it, doors facing out, with yards and courtyards left
+//! behind them. Then each building is given a use to suit its quarter, and
+//! the shops are chosen from buildings in the right places: the broker and
+//! the shipyard by the gate you came in by, the tavern by the square.
+//!
+//! The world has a say too (`Lay`): how many parks there are, how big their
+//! ponds, how thick the trees and what's scattered outside the walls. On
+//! an ocean world the city stands on an island, its landing field out on a
+//! spit of sand and its other roads running out to sea as causeways.
+
+use rand::RngExt;
+use rand::seq::{IndexedRandom, SliceRandom};
+
+use super::words;
+use super::*;
+use crate::seed::Rng;
+
+/// Columns west of the city wall, for the landing field and the road in.
+const FIELD: i32 = 26;
+/// The landing field's east edge.
+const PAD_RIGHT: i32 = 19;
+/// How far the wall's wobble can push it out, as a share of its radius.
+const REACH: f64 = 1.08;
+
+/// How a world bends a city's plan. Each is times what it'd be otherwise.
+struct Lay {
+ parks: f64,
+ trees: f64,
+ /// How big ponds are. None at all at 0.
+ ponds: f64,
+ /// The chance of something standing on any bit of ground outside the
+ /// walls (not times anything).
+ wild: f64,
+ /// Whether the sea comes up round the walls.
+ sea: bool,
+}
+
+fn lay(terrain: Terrain) -> Lay {
+ let lay = |parks, trees, ponds, wild| Lay { parks, trees, ponds, wild, sea: false };
+ match terrain {
+ // Few parks, small cisterns, little shade.
+ Terrain::Desert => lay(0.35, 0.5, 0.55, 0.04),
+ Terrain::Ice => lay(0.6, 0.6, 1.0, 0.06),
+ Terrain::Ocean => Lay { sea: true, ..lay(1.0, 1.0, 1.2, 0.05) },
+ // Parks everywhere, trees crowding every yard.
+ Terrain::Jungle => lay(3.0, 3.0, 1.0, 0.3),
+ // Nothing pools on airless rock.
+ Terrain::Barren | Terrain::Clouds => lay(0.6, 0.8, 0.0, 0.05),
+ Terrain::Forge => lay(0.7, 0.7, 0.7, 0.06),
+ }
+}
+
+fn lerp(a: f64, b: f64, t: f64) -> f64 {
+ a + (b - a) * t
+}
+
+/// The line of the city wall: a superellipse, wobbled.
+struct WallShape {
+ cx: f64,
+ cy: f64,
+ rx: f64,
+ ry: f64,
+ /// 2 is an ellipse; the higher, the squarer.
+ n: f64,
+ /// Sine waves on the radius: (how many round the wall, how big, where).
+ wobble: [(f64, f64, f64); 2],
+}
+
+impl WallShape {
+ fn inside(&self, p: Pos) -> bool {
+ let dx = (f64::from(p.x) + 0.5 - self.cx) / self.rx;
+ let dy = (f64::from(p.y) + 0.5 - self.cy) / self.ry;
+ let d = (dx.abs().powf(self.n) + dy.abs().powf(self.n)).powf(1.0 / self.n);
+ let angle = dy.atan2(dx);
+ let r =
+ 1.0 + self.wobble.iter().map(|&(k, amp, at)| amp * (k * angle + at).sin()).sum::<f64>();
+ d <= r
+ }
+}
+
+/// How big blocks get in a quarter before a street splits them: columns
+/// and rows.
+fn block_size(q: QuarterKind) -> (i32, i32) {
+ match q {
+ QuarterKind::Poor => (14, 6),
+ QuarterKind::Homes => (20, 8),
+ QuarterKind::Market => (18, 8),
+ QuarterKind::Gate => (20, 8),
+ QuarterKind::Grand => (30, 12),
+ QuarterKind::Holy => (24, 10),
+ QuarterKind::Works => (26, 10),
+ QuarterKind::Gardens => (26, 11),
+ }
+}
+
+/// How wide a street is, columns for one running north–south and rows for
+/// one running east–west. A row is twice as tall as a column is wide, so
+/// (2, 1) looks the same either way.
+fn street_width(q: QuarterKind, depth: u32, rng: &mut Rng) -> (i32, i32) {
+ use QuarterKind::*;
+ match (q, depth) {
+ (Poor, 0) => (2, 1),
+ (Poor, _) => (1, 1),
+ (_, 0) | (Grand | Market | Gate, 1) => (3, 2),
+ (Homes, 3..) if rng.random_bool(0.3) => (1, 1),
+ _ => (2, 1),
+ }
+}
+
+/// The sizes of building a quarter packs along its streets.
+struct Lots {
+ /// For doors facing north or south: columns along the street, and rows
+ /// deep.
+ wide: (i32, i32),
+ deep: (i32, i32),
+ /// For doors facing east or west: rows along the street, and columns deep.
+ tall: (i32, i32),
+ long: (i32, i32),
+ /// The chance of a gap after each building.
+ gap: f64,
+}
+
+fn lots(q: QuarterKind) -> Lots {
+ let lots = |wide, deep, tall, long, gap| Lots { wide, deep, tall, long, gap };
+ match q {
+ QuarterKind::Poor => lots((4, 6), (2, 3), (3, 3), (4, 6), 0.12),
+ QuarterKind::Homes => lots((5, 8), (2, 4), (3, 4), (5, 8), 0.3),
+ QuarterKind::Market => lots((6, 11), (3, 4), (3, 4), (6, 10), 0.15),
+ QuarterKind::Gate => lots((6, 11), (3, 4), (3, 4), (6, 10), 0.25),
+ QuarterKind::Grand => lots((8, 13), (3, 5), (3, 5), (8, 12), 0.45),
+ QuarterKind::Holy => lots((5, 9), (3, 4), (3, 4), (5, 9), 0.35),
+ QuarterKind::Works => lots((7, 13), (3, 5), (3, 5), (7, 12), 0.2),
+ QuarterKind::Gardens => lots((5, 9), (2, 4), (3, 4), (5, 8), 0.6),
+ }
+}
+
+/// What the buildings in a quarter tend to be, and how often.
+fn uses(q: QuarterKind, culture: Culture) -> Vec<(Kind, f64)> {
+ use Kind::*;
+ let baths = if matches!(culture, Culture::Qaari | Culture::Houses) { 1.0 } else { 0.0 };
+ match q {
+ QuarterKind::Market => vec![
+ (House, 3.0),
+ (Workshop, 2.5),
+ (Warehouse, 1.0),
+ (Tenement, 1.0),
+ (Shrine, 0.3),
+ (Bathhouse, 0.6 * baths),
+ ],
+ QuarterKind::Gate => {
+ vec![(Warehouse, 3.0), (House, 2.0), (Workshop, 1.5), (Tenement, 1.0), (Shrine, 0.2)]
+ }
+ QuarterKind::Homes => vec![
+ (House, 8.0),
+ (Tenement, 1.5),
+ (Shrine, 0.5),
+ (Workshop, 1.0),
+ (Bathhouse, 0.4 * baths),
+ ],
+ QuarterKind::Poor => vec![(Tenement, 5.0), (House, 3.0), (Workshop, 1.0), (Shrine, 0.6)],
+ QuarterKind::Grand => {
+ vec![(Manor, 6.0), (House, 1.5), (Shrine, 0.5), (Bathhouse, 0.6 * baths)]
+ }
+ QuarterKind::Holy => vec![(Shrine, 2.5), (House, 3.0), (Tenement, 1.0), (Workshop, 1.0)],
+ QuarterKind::Works => {
+ vec![(Workshop, 6.0), (Warehouse, 4.0), (Tenement, 2.0), (House, 1.0)]
+ }
+ QuarterKind::Gardens => {
+ vec![(House, 3.0), (Manor, 2.0), (Shrine, 1.0), (Bathhouse, 0.6 * baths)]
+ }
+ }
+}
+
+/// Whether a building this many columns and rows is big enough to be one
+/// of these.
+fn fits(kind: Kind, w: i32, h: i32) -> bool {
+ let area = w * h;
+ match kind {
+ Kind::Manor => area >= 21,
+ Kind::Warehouse | Kind::Bathhouse => area >= 18,
+ Kind::Tenement => area >= 15,
+ _ => true,
+ }
+}
+
+/// How likely each kind of building is to have its door barred to you.
+fn barred_chance(kind: Kind) -> f64 {
+ match kind {
+ Kind::House => 0.35,
+ Kind::Manor => 0.5,
+ Kind::Warehouse => 0.4,
+ Kind::Tenement => 0.15,
+ Kind::Workshop => 0.05,
+ _ => 0.0,
+ }
+}
+
+struct Planner<'a> {
+ t: Town,
+ rng: &'a mut Rng,
+ lay: Lay,
+ wall: WallShape,
+ size: f64,
+ main: i32,
+ square: Rect,
+ /// Quarter kinds and the points they're centred on.
+ points: Vec<(QuarterKind, Pos)>,
+ /// Blocks between streets, and whether each has been used for
+ /// something already.
+ blocks: Vec<(Rect, bool)>,
+ /// Parks, which get more trees.
+ parks: Vec<Rect>,
+ /// Market stalls, each with its keeper's place in front.
+ stalls: Vec<Pos>,
+ /// The grounds of the palace and the temple, which streets go round.
+ grounds: Vec<Rect>,
+}
+
+pub(super) fn build(
+ name: String,
+ culture: Culture,
+ land: Land,
+ seed: u64,
+ size: f64,
+ rng: &mut Rng,
+) -> Town {
+ let rx = lerp(60.0, 116.0, size) * rng.random_range(0.92..1.08);
+ let ry = rx * rng.random_range(0.40..0.50);
+ let width = FIELD + (2.0 * rx * REACH).ceil() as i32 + 6;
+ let height = (2.0 * ry * REACH).ceil() as i32 + 10;
+ let main = height / 2;
+ let mut wobble = || {
+ (
+ f64::from(rng.random_range(3..=7)),
+ rng.random_range(0.0..0.035),
+ rng.random_range(0.0..6.3),
+ )
+ };
+ let wobble = [wobble(), wobble()];
+ let wall = WallShape {
+ cx: f64::from(FIELD) + rx * REACH,
+ cy: f64::from(main) + 0.5,
+ rx,
+ ry,
+ n: rng.random_range(2.3..4.0),
+ wobble,
+ };
+ let cells = (width * height) as usize;
+ let t = Town {
+ name,
+ culture,
+ land,
+ seed,
+ site: Site::new(width, height, Tile::Ground, Tile::CityWall, true),
+ owner: vec![NO_OWNER; cells],
+ quarter_of: vec![NO_QUARTER; cells],
+ quarters: Vec::new(),
+ buildings: Vec::new(),
+ shops: Vec::new(),
+ ship: Pos::new(7, main - 1),
+ start: Pos::new(13, main),
+ square: Pos::new(0, main),
+ gates: Vec::new(),
+ };
+ let mut p = Planner {
+ t,
+ rng,
+ lay: lay(land.terrain),
+ wall,
+ size,
+ main,
+ square: Rect::new(0, 0, 0, 0),
+ points: Vec::new(),
+ blocks: Vec::new(),
+ parks: Vec::new(),
+ stalls: Vec::new(),
+ grounds: Vec::new(),
+ };
+ p.walls();
+ p.field();
+ let sides = p.gates();
+ p.market_square();
+ p.avenues(&sides);
+ p.quarters();
+ p.landmarks();
+ let inside = p.inside_bounds();
+ p.split(inside, 0);
+ for b in 0..p.t.buildings.len() {
+ let front = p.t.buildings[b].front();
+ p.link(front);
+ }
+ p.parks_and_plazas();
+ let blocks: Vec<Rect> = p.blocks.iter().filter(|(_, used)| !used).map(|&(r, _)| r).collect();
+ for r in blocks {
+ p.pack(r);
+ }
+ p.uses();
+ p.shops();
+ p.guardhouses();
+ if p.lay.sea {
+ p.sea();
+ }
+ p.dress();
+ p.people();
+ p.t
+}
+
+impl Planner<'_> {
+ fn tile(&self, p: Pos) -> Tile {
+ self.t.site.tile(p)
+ }
+
+ fn set(&mut self, p: Pos, tile: Tile) {
+ if self.t.site.contains(p) {
+ self.t.site.set(p, tile);
+ }
+ }
+
+ /// Paves over open ground: streets go round what's already built, and
+ /// round landmarks' grounds.
+ fn carve(&mut self, r: Rect) {
+ for p in r.cells() {
+ if self.tile(p) == Tile::Garden && !self.reserved(p) {
+ self.set(p, Tile::Street);
+ }
+ }
+ }
+
+ fn walls(&mut self) {
+ let site = &self.t.site;
+ let all: Vec<Pos> = site.positions().collect();
+ for &p in &all {
+ if self.wall.inside(p) {
+ let edge = p.neighbours().any(|n| !self.wall.inside(n));
+ self.set(p, if edge { Tile::CityWall } else { Tile::Garden });
+ }
+ }
+ // A road inside the wall, all the way round.
+ let ring: Vec<Pos> = all
+ .into_iter()
+ .filter(|&p| {
+ self.tile(p) == Tile::Garden
+ && p.neighbours().any(|n| self.tile(n) == Tile::CityWall)
+ })
+ .collect();
+ for p in ring {
+ self.set(p, Tile::Street);
+ }
+ }
+
+ /// The landing field, with your ship on it.
+ fn field(&mut self) {
+ let main = self.main;
+ self.t.site.fill(Rect::new(3, main - 5, PAD_RIGHT, main + 5), Tile::Pad);
+ let (sw, sh) = SHIP_SIZE;
+ let ship = self.t.ship;
+ self.t.site.fill(Rect::new(ship.x, ship.y, ship.x + sw - 1, ship.y + sh - 1), Tile::Ship);
+ }
+
+ /// Cuts a gate through the wall along each lane, paving the way to it
+ /// from outside, and returns the cell just inside the middle lane.
+ fn gate(&mut self, lanes: &[Pos], (dx, dy): (i32, i32)) -> Pos {
+ let mut inner = Vec::new();
+ for &start in lanes {
+ let mut p = start;
+ while self.t.site.contains(p) && self.tile(p) != Tile::CityWall {
+ if self.tile(p) == Tile::Ground {
+ self.set(p, Tile::Street);
+ }
+ p = p.offset(dx, dy);
+ }
+ while self.tile(p) == Tile::CityWall && self.t.site.contains(p) {
+ self.set(p, Tile::Gate);
+ p = p.offset(dx, dy);
+ }
+ inner.push(p);
+ }
+ inner[lanes.len() / 2]
+ }
+
+ /// The west gate, and one to three others. Returns which side each
+ /// gate is on, in the order of `Town::gates`.
+ fn gates(&mut self) -> Vec<Side> {
+ let main = self.main;
+ let west: Vec<Pos> = (main - 1..=main + 1).map(|y| Pos::new(PAD_RIGHT + 1, y)).collect();
+ let inner = self.gate(&west, (1, 0));
+ self.t.gates.push(inner);
+ let mut sides = vec![Side::North, Side::South, Side::East];
+ sides.shuffle(self.rng);
+ let extra = 1 + self.rng.random_range(0..=(self.size * 2.0).round() as usize);
+ sides.truncate(extra);
+ let (w, h) = (self.t.width(), self.t.height());
+ for &side in &sides {
+ let inner = match side {
+ Side::North | Side::South => {
+ let x = (self.wall.cx + self.wall.rx * self.rng.random_range(-0.45..0.45))
+ .round() as i32;
+ let (y, dy) = if side == Side::North { (0, 1) } else { (h - 1, -1) };
+ let lanes: Vec<Pos> = (x - 2..=x + 1).map(|x| Pos::new(x, y)).collect();
+ self.gate(&lanes, (0, dy))
+ }
+ _ => {
+ let y = (self.wall.cy + self.wall.ry * self.rng.random_range(-0.35..0.35))
+ .round() as i32;
+ let lanes: Vec<Pos> = (y - 1..=y + 1).map(|y| Pos::new(w - 1, y)).collect();
+ self.gate(&lanes, (-1, 0))
+ }
+ };
+ self.t.gates.push(inner);
+ }
+ let mut all = vec![Side::West];
+ all.extend(sides);
+ all
+ }
+
+ fn market_square(&mut self) {
+ let size = self.size;
+ let w = lerp(20.0, 36.0, size) as i32 + self.rng.random_range(0..5);
+ let h = lerp(7.0, 12.0, size) as i32 + self.rng.random_range(0..2);
+ let x = (self.wall.cx + self.wall.rx * self.rng.random_range(-0.12..0.12)).round() as i32;
+ let top = self.main - h / 2 + self.rng.random_range(-1..=1);
+ let r = Rect::new(x - w / 2, top, x - w / 2 + w - 1, top + h - 1);
+ for p in r.cells() {
+ if matches!(self.tile(p), Tile::Garden | Tile::Street) {
+ self.set(p, Tile::Square);
+ }
+ }
+ self.square = r;
+ self.t.square = Pos::new(x, self.main);
+ for dx in -1..=1 {
+ self.set(Pos::new(x + dx, self.main), Tile::Centrepiece);
+ }
+ }
+
+ /// Paves an L from `a` to `b`, `cols` wide where it runs north–south
+ /// and `rows` tall where it runs east–west.
+ fn road(&mut self, a: Pos, b: Pos, (cols, rows): (i32, i32), down_first: bool) {
+ let vertical = |x: i32, y0: i32, y1: i32| {
+ Rect::new(x - cols / 2, y0.min(y1), x - cols / 2 + cols - 1, y0.max(y1))
+ };
+ let across = |y: i32, x0: i32, x1: i32| {
+ Rect::new(x0.min(x1), y - rows / 2, x0.max(x1), y - rows / 2 + rows - 1)
+ };
+ if down_first {
+ self.carve(vertical(a.x, a.y, b.y));
+ self.carve(across(b.y, a.x, b.x));
+ } else {
+ self.carve(across(a.y, a.x, b.x));
+ self.carve(vertical(b.x, a.y, b.y));
+ }
+ }
+
+ fn avenues(&mut self, sides: &[Side]) {
+ let sq = self.square;
+ for (i, &side) in sides.iter().enumerate() {
+ let g = self.t.gates[i];
+ match side {
+ Side::West => self.road(g, Pos::new(sq.left + 1, self.main), (4, 3), false),
+ Side::North | Side::South => {
+ let y = if side == Side::North { sq.top + 1 } else { sq.bottom - 1 };
+ let to = Pos::new(g.x.clamp(sq.left + 2, sq.right - 2), y);
+ self.road(g, to, (4, 3), true);
+ }
+ Side::East => {
+ let to = Pos::new(sq.right - 2, g.y.clamp(sq.top + 1, sq.bottom - 1));
+ self.road(g, to, (4, 3), false);
+ }
+ }
+ }
+ }
+
+ fn inside_bounds(&self) -> Rect {
+ let inside: Vec<Pos> =
+ self.t.site.positions().filter(|&p| self.tile(p) != Tile::Ground).collect();
+ let xs = inside.iter().filter(|p| p.x > PAD_RIGHT + 1).map(|p| p.x);
+ let ys = inside.iter().map(|p| p.y);
+ Rect::new(
+ xs.clone().min().unwrap_or(0),
+ ys.clone().min().unwrap_or(0),
+ xs.max().unwrap_or(0),
+ ys.max().unwrap_or(0),
+ )
+ }
+
+ fn nearest_quarter(&self, p: Pos) -> usize {
+ (0..self.points.len()).min_by_key(|&i| distance2(self.points[i].1, p)).unwrap_or(0)
+ }
+
+ fn quarter_kind(&self, p: Pos) -> QuarterKind {
+ self.points[self.nearest_quarter(p)].0
+ }
+
+ fn quarters(&mut self) {
+ use QuarterKind::*;
+ let count = 4 + (self.size * 4.0).round() as usize;
+ let gate = self.t.gates[0];
+ self.points.push((Market, self.t.square));
+ self.points.push((Gate, gate.offset((self.wall.rx * 0.3) as i32, 0)));
+ let mut rest = vec![Homes, Works, Poor, Gardens];
+ rest.shuffle(self.rng);
+ let kinds = [Grand, Holy].into_iter().chain(rest).chain([Homes, Poor, Homes, Works]);
+ let inside: Vec<Pos> =
+ self.t.site.positions().filter(|&p| self.tile(p) == Tile::Garden).collect();
+ for kind in kinds.take(count - 2) {
+ // Of a dozen places, the one furthest from the others.
+ let tries: Vec<Pos> =
+ (0..12).filter_map(|_| inside.choose(self.rng).copied()).collect();
+ let far = tries.into_iter().max_by_key(|&p| {
+ self.points.iter().map(|&(_, q)| distance2(p, q)).min().unwrap_or(0)
+ });
+ if let Some(p) = far {
+ self.points.push((kind, p));
+ }
+ }
+ let mut sums = vec![(0i64, 0i64, 0i64); self.points.len()];
+ for p in self.t.site.positions().collect::<Vec<_>>() {
+ if self.tile(p) != Tile::Ground && p.x > PAD_RIGHT {
+ let q = self.nearest_quarter(p);
+ let i = self.t.site.index(p);
+ self.t.quarter_of[i] = q as u8;
+ sums[q] = (sums[q].0 + i64::from(p.x), sums[q].1 + i64::from(p.y), sums[q].2 + 1);
+ }
+ }
+ let culture = self.t.culture;
+ for (i, &(kind, at)) in self.points.iter().enumerate() {
+ let (sx, sy, n) = sums[i];
+ let centre = if n > 0 { Pos::new((sx / n) as i32, (sy / n) as i32) } else { at };
+ let mut name = words::quarter_name(culture, kind, self.rng);
+ for _ in 0..10 {
+ if !self.t.quarters.iter().any(|q| q.name == name) {
+ break;
+ }
+ name = words::quarter_name(culture, kind, self.rng);
+ }
+ self.t.quarters.push(Quarter { kind, name, centre });
+ }
+ }
+
+ /// Splits `r` with a street, and its halves, and theirs, until each is
+ /// small enough to be a block in its quarter.
+ fn split(&mut self, r: Rect, depth: u32) {
+ let kind = self.quarter_kind(r.centre());
+ let (max_w, max_h) = block_size(kind);
+ let (w, h) = (r.width(), r.height());
+ let (wide, tall) = (w > max_w, h > max_h);
+ let nearly = w <= max_w * 3 / 2 && h <= max_h * 3 / 2;
+ if !wide && !tall || w < 8 || h < 5 || depth > 2 && nearly && self.rng.random_bool(0.15) {
+ self.blocks.push((r, false));
+ return;
+ }
+ let upright = if wide && tall {
+ f64::from(w) > 2.0 * f64::from(h) * self.rng.random_range(0.8..1.25)
+ } else {
+ wide
+ };
+ let (cols, rows) = street_width(kind, depth, self.rng);
+ if upright {
+ let lo = r.left + w * 3 / 10;
+ let hi = (r.right - w * 3 / 10 - cols + 1).max(lo);
+ let x = self.rng.random_range(lo..=hi);
+ self.carve(Rect::new(x, r.top, x + cols - 1, r.bottom));
+ self.split(Rect::new(r.left, r.top, x - 1, r.bottom), depth + 1);
+ self.split(Rect::new(x + cols, r.top, r.right, r.bottom), depth + 1);
+ } else {
+ let lo = r.top + h * 3 / 10;
+ let hi = (r.bottom - h * 3 / 10 - rows + 1).max(lo);
+ let y = self.rng.random_range(lo..=hi);
+ self.carve(Rect::new(r.left, y, r.right, y + rows - 1));
+ self.split(Rect::new(r.left, r.top, r.right, y - 1), depth + 1);
+ self.split(Rect::new(r.left, y + rows, r.right, r.bottom), depth + 1);
+ }
+ }
+
+ /// Whether every cell of `r` is open ground: nothing built, no street
+ /// through it, all inside the wall, and not set aside for a landmark.
+ fn whole(&self, r: Rect) -> bool {
+ r.cells().all(|p| self.tile(p) == Tile::Garden && !self.reserved(p))
+ }
+
+ /// Whether `p` is in the grounds of a landmark.
+ fn reserved(&self, p: Pos) -> bool {
+ self.grounds.iter().any(|r| r.contains(p))
+ }
+
+ fn add_building(&mut self, kind: Kind, foot: Rect, door: Pos, facing: Side) -> usize {
+ let b = self.t.buildings.len();
+ for p in foot.cells() {
+ self.set(p, Tile::Roof);
+ let i = self.t.site.index(p);
+ self.t.owner[i] = b as u16;
+ }
+ self.set(door, Tile::Door { open: true });
+ let name = String::new();
+ self.t.buildings.push(Building {
+ kind,
+ left: foot.left,
+ top: foot.top,
+ right: foot.right,
+ bottom: foot.bottom,
+ door,
+ facing,
+ barred: false,
+ shop: None,
+ name,
+ });
+ b
+ }
+
+ /// Paves from `from` in direction `step` until it meets a street.
+ fn pave_to_street(&mut self, from: Pos, (dx, dy): (i32, i32)) {
+ let mut p = from;
+ for _ in 0..12 {
+ if self.tile(p) != Tile::Garden {
+ return;
+ }
+ self.set(p, Tile::Square);
+ p = p.offset(dx, dy);
+ }
+ }
+
+ /// Sets aside grounds for a great building before the streets are cut,
+ /// as near the middle of its quarter as they'll fit, with a street all
+ /// round them and a paved way from the door to it. Shrinks the building
+ /// if it won't fit anywhere.
+ fn landmark(&mut self, kind: Kind, home: QuarterKind, (mut w, mut h): (i32, i32)) {
+ let target =
+ self.points.iter().find(|(k, _)| *k == home).map_or(self.t.square, |&(_, p)| p);
+ let mut open: Vec<Pos> =
+ self.t.site.positions().filter(|&p| self.tile(p) == Tile::Garden).collect();
+ open.sort_by_key(|&p| distance2(p, target));
+ let around = |r: Rect| Rect::new(r.left - 2, r.top - 1, r.right + 2, r.bottom + 1);
+ loop {
+ let (gw, gh) = (w + 6, h + 2);
+ let at = |c: Pos| {
+ let (left, top) = (c.x - gw / 2, c.y - gh / 2);
+ Rect::new(left, top, left + gw - 1, top + gh - 1)
+ };
+ let found = open.iter().map(|&c| at(c)).find(|&grounds| {
+ self.whole(grounds)
+ && around(grounds).cells().all(|p| {
+ grounds.contains(p)
+ || !self.reserved(p)
+ && matches!(
+ self.tile(p),
+ Tile::Garden | Tile::Street | Tile::Square
+ )
+ })
+ });
+ if let Some(grounds) = found {
+ self.grounds.push(grounds);
+ self.carve(around(grounds));
+ let (left, top) = (grounds.left + 3, grounds.top + 1);
+ let foot = Rect::new(left, top, left + w - 1, top + h - 1);
+ // The door faces the square, or at least its side of the city.
+ let facing = if grounds.centre().y < self.main { Side::South } else { Side::North };
+ let x = foot.centre().x;
+ let door = Pos::new(x, if facing == Side::South { foot.bottom } else { foot.top });
+ let step = facing.step();
+ for dx in -1..=1 {
+ self.pave_to_street(door.offset(dx + step.0, step.1), step);
+ }
+ let front = door.offset(0, step.1);
+ for dx in [-3, 3] {
+ if self.tile(front.offset(dx, 0)) == Tile::Garden {
+ self.set(front.offset(dx, 0), Tile::Statue);
+ }
+ }
+ self.add_building(kind, foot, door, facing);
+ return;
+ }
+ if w <= 8 {
+ return;
+ }
+ w -= 2;
+ h = (h - 1).max(3);
+ }
+ }
+
+ fn landmarks(&mut self) {
+ let size = self.size;
+ let palace = (lerp(14.0, 22.0, size) as i32, lerp(5.0, 8.0, size) as i32);
+ let temple = (lerp(10.0, 16.0, size) as i32, lerp(4.0, 6.0, size) as i32);
+ self.landmark(Kind::Palace, QuarterKind::Grand, palace);
+ self.landmark(Kind::Temple, QuarterKind::Holy, temple);
+ }
+
+ /// The paved cells you can walk to from `from` without leaving the
+ /// streets.
+ fn paved_from(&self, from: Pos) -> Vec<bool> {
+ let site = &self.t.site;
+ let paved =
+ |p: Pos| site.contains(p) && self.tile(p).outdoors() && self.tile(p) != Tile::Garden;
+ let mut seen = vec![false; (site.width * site.height) as usize];
+ if !paved(from) {
+ return seen;
+ }
+ let mut queue = std::collections::VecDeque::from([from]);
+ seen[site.index(from)] = true;
+ while let Some(p) = queue.pop_front() {
+ for n in p.neighbours() {
+ if paved(n) && !seen[site.index(n)] {
+ seen[site.index(n)] = true;
+ queue.push_back(n);
+ }
+ }
+ }
+ seen
+ }
+
+ /// Makes sure you can walk by street from the west gate to `to`, paving
+ /// a road between the nearest two streets if not.
+ fn link(&mut self, to: Pos) {
+ let reached = self.paved_from(self.t.gates[0]);
+ let site = &self.t.site;
+ if reached[site.index(to)] {
+ return;
+ }
+ let cut_off = self.paved_from(to);
+ let ours: Vec<Pos> = site.positions().filter(|&p| reached[site.index(p)]).collect();
+ let theirs = site.positions().filter(|&p| cut_off[site.index(p)]);
+ let pair = theirs
+ .flat_map(|a| ours.iter().map(move |&b| (a, b)))
+ .min_by_key(|&(a, b)| distance2(a, b));
+ if let Some((a, b)) = pair {
+ let down_first = self.rng.random_bool(0.5);
+ self.road(a, b, (2, 1), down_first);
+ }
+ }
+
+ fn parks_and_plazas(&mut self) {
+ for i in 0..self.blocks.len() {
+ let (r, used) = self.blocks[i];
+ if used || r.width() < 6 || r.height() < 3 || r.cells().any(|p| self.reserved(p)) {
+ continue;
+ }
+ let q = self.quarter_kind(r.centre());
+ let park = match q {
+ QuarterKind::Gardens => 0.55,
+ QuarterKind::Market | QuarterKind::Gate => 0.0,
+ _ => 0.03,
+ };
+ let plaza = match q {
+ QuarterKind::Gardens | QuarterKind::Grand => 0.0,
+ _ if r.area() > 200 => 0.0,
+ _ => 0.03,
+ };
+ if self.rng.random_bool((park * self.lay.parks).min(0.95)) {
+ self.blocks[i].1 = true;
+ self.park(r);
+ } else if self.rng.random_bool(plaza) {
+ self.blocks[i].1 = true;
+ self.plaza(r);
+ }
+ }
+ }
+
+ /// Puts something in the middle of a park or plaza, if the middle is
+ /// open ground inside the walls.
+ fn centrepiece(&mut self, at: Pos, tile: Tile) {
+ if matches!(self.tile(at), Tile::Garden | Tile::Street | Tile::Square) {
+ self.set(at, tile);
+ }
+ }
+
+ /// Paths crossing, a pond if there's room, and trees later.
+ fn park(&mut self, r: Rect) {
+ self.parks.push(r);
+ let c = r.centre();
+ let path = |p: &mut Planner, cell: Pos| {
+ if p.tile(cell) == Tile::Garden {
+ p.set(cell, Tile::Street);
+ }
+ };
+ for x in r.left..=r.right {
+ path(self, Pos::new(x, c.y));
+ }
+ for y in r.top..=r.bottom {
+ path(self, Pos::new(c.x, y));
+ path(self, Pos::new(c.x + 1, y));
+ }
+ let size = self.lay.ponds;
+ if size > 0.0 && r.width() >= 14 && r.height() >= 6 {
+ // In one corner, between the paths.
+ let qx =
+ if self.rng.random_bool(0.5) { (r.left + c.x) / 2 } else { (c.x + r.right) / 2 };
+ let qy =
+ if self.rng.random_bool(0.5) { (r.top + c.y) / 2 } else { (c.y + r.bottom) / 2 };
+ let (ax, ay) = (f64::from(r.width()) / 7.0 * size, f64::from(r.height()) / 5.0 * size);
+ for p in r.cells() {
+ let dx = f64::from(p.x - qx) / ax.max(1.0);
+ let dy = f64::from(p.y - qy) / ay.max(0.8);
+ if dx * dx + dy * dy <= 1.0 && self.tile(p) == Tile::Garden {
+ self.set(p, Tile::Water);
+ }
+ }
+ }
+ let centre = if self.rng.random_bool(0.5) { Tile::Statue } else { Tile::Well };
+ self.centrepiece(c, centre);
+ }
+
+ /// A little square with a well or a statue in the middle.
+ fn plaza(&mut self, r: Rect) {
+ for p in r.cells() {
+ if self.tile(p) == Tile::Garden {
+ self.set(p, Tile::Square);
+ }
+ }
+ let wells = matches!(self.t.culture, Culture::Qaari | Culture::Drift);
+ let centre = if wells != self.rng.random_bool(0.25) { Tile::Well } else { Tile::Statue };
+ self.centrepiece(r.centre(), centre);
+ }
+
+ /// Packs buildings along the streets round a block, the long sides
+ /// first, then the ends.
+ fn pack(&mut self, r: Rect) {
+ let lots = lots(self.quarter_kind(r.centre()));
+ let mut long = [Side::North, Side::South];
+ let mut ends = [Side::West, Side::East];
+ long.shuffle(self.rng);
+ ends.shuffle(self.rng);
+ for side in long.into_iter().chain(ends) {
+ self.pack_side(r, side, &lots);
+ }
+ }
+
+ fn pack_side(&mut self, r: Rect, side: Side, lots: &Lots) {
+ let upright = matches!(side, Side::North | Side::South);
+ let (len, cap) = if upright {
+ (r.width(), (r.height() + 1) / 2)
+ } else {
+ (r.height(), (r.width() + 1) / 2)
+ };
+ let (along, deep) = if upright { (lots.wide, lots.deep) } else { (lots.tall, lots.long) };
+ let min_deep = if upright { 2 } else { 3 };
+ let mut u = 0;
+ while u < len {
+ let want = self.rng.random_range(along.0..=along.1);
+ let depth = self.rng.random_range(deep.0..=deep.1).min(cap);
+ if depth < min_deep {
+ return;
+ }
+ let mut placed = None;
+ for n in (along.0.min(want)..=want).rev() {
+ let foot = match side {
+ Side::North => {
+ Rect::new(r.left + u, r.top, r.left + u + n - 1, r.top + depth - 1)
+ }
+ Side::South => {
+ Rect::new(r.left + u, r.bottom - depth + 1, r.left + u + n - 1, r.bottom)
+ }
+ Side::West => {
+ Rect::new(r.left, r.top + u, r.left + depth - 1, r.top + u + n - 1)
+ }
+ Side::East => {
+ Rect::new(r.right - depth + 1, r.top + u, r.right, r.top + u + n - 1)
+ }
+ };
+ let inside = foot.right <= r.right && foot.bottom <= r.bottom;
+ if inside
+ && self.whole(foot)
+ && let Some(door) = self.door_for(foot, side)
+ {
+ self.add_building(Kind::House, foot, door, side);
+ placed = Some(n);
+ break;
+ }
+ }
+ match placed {
+ Some(n) => {
+ let gap = if self.rng.random_bool(lots.gap) {
+ if self.rng.random_bool(0.25) { 2 } else { 1 }
+ } else {
+ 0
+ };
+ u += n + gap;
+ }
+ None => u += 1,
+ }
+ }
+ }
+
+ /// A door on the `side` edge of a footprint, away from the corners,
+ /// that opens onto a street.
+ fn door_for(&mut self, foot: Rect, side: Side) -> Option<Pos> {
+ let (dx, dy) = side.step();
+ let doors: Vec<Pos> = match side {
+ Side::North | Side::South => {
+ let y = if side == Side::North { foot.top } else { foot.bottom };
+ (foot.left + 1..foot.right).map(|x| Pos::new(x, y)).collect()
+ }
+ Side::West | Side::East => {
+ let x = if side == Side::West { foot.left } else { foot.right };
+ (foot.top + 1..foot.bottom).map(|y| Pos::new(x, y)).collect()
+ }
+ };
+ let good: Vec<Pos> =
+ doors.into_iter().filter(|d| self.tile(d.offset(dx, dy)).paved()).collect();
+ good.choose(self.rng).copied()
+ }
+
+ /// Decides what each ordinary building is, by its quarter and size, and
+ /// names it.
+ fn uses(&mut self) {
+ let culture = self.t.culture;
+ for b in 0..self.t.buildings.len() {
+ let building = &self.t.buildings[b];
+ let (w, h) = (building.width(), building.height());
+ let mut kind = building.kind;
+ if kind == Kind::House {
+ let q = self.quarter_kind(building.centre());
+ let options = uses(q, culture);
+ kind = options
+ .choose_weighted(self.rng, |(_, weight)| *weight)
+ .map_or(Kind::House, |(k, _)| *k);
+ if !fits(kind, w, h) {
+ kind = Kind::House;
+ }
+ }
+ let barred = self.rng.random_bool(barred_chance(kind));
+ let name = words::building_name(kind, culture, self.rng);
+ let building = &mut self.t.buildings[b];
+ building.kind = kind;
+ building.barred = barred;
+ building.name = name;
+ let door = building.door;
+ self.set(door, Tile::Door { open: !barred });
+ }
+ }
+
+ fn make(&mut self, b: usize, kind: Kind) {
+ let culture = self.t.culture;
+ let name = words::building_name(kind, culture, self.rng);
+ let building = &mut self.t.buildings[b];
+ building.kind = kind;
+ building.barred = false;
+ building.name = name;
+ let door = building.door;
+ self.set(door, Tile::Door { open: true });
+ }
+
+ /// Picks one of the ordinary buildings nearest to `near`, and wide
+ /// enough for a sign `sign` long.
+ /// An ordinary building near `near` with room for the sign over its
+ /// door, and not just down the street from another shop of the same
+ /// kind.
+ fn site_near(&mut self, near: Pos, kind: ShopKind) -> Option<usize> {
+ let need = kind.sign().len() as i32 + 2;
+ let rivals: Vec<Pos> = self
+ .t
+ .shops
+ .iter()
+ .filter(|s| s.kind == kind)
+ .map(|s| self.t.buildings[s.building].door)
+ .collect();
+ let mut options: Vec<usize> = (0..self.t.buildings.len())
+ .filter(|&b| {
+ let building = &self.t.buildings[b];
+ let ordinary = matches!(
+ building.kind,
+ Kind::House | Kind::Workshop | Kind::Warehouse | Kind::Tenement | Kind::Shrine
+ );
+ ordinary
+ && building.width() >= need
+ && building.height() >= 3
+ && rivals.iter().all(|&r| distance2(r, building.door) > 24 * 24)
+ })
+ .collect();
+ options.sort_by_key(|&b| distance2(self.t.buildings[b].door, near));
+ options.truncate(4);
+ options.choose(self.rng).copied()
+ }
+
+ fn shops(&mut self) {
+ let culture = self.t.culture;
+ let anchor = |p: &Planner, kinds: &[QuarterKind]| {
+ let quarters: Vec<&Quarter> =
+ p.t.quarters.iter().filter(|q| kinds.contains(&q.kind)).collect();
+ quarters.first().map(|q| q.centre)
+ };
+ let gate = self.t.gates[0].offset(8, 0);
+ let square = self.t.square;
+ let temple = self.t.buildings.iter().find(|b| b.kind == Kind::Temple).map(|b| b.front());
+ let scholar = anchor(self, &[QuarterKind::Holy]).or(temple).unwrap_or(square);
+ let homes = anchor(self, &[QuarterKind::Homes, QuarterKind::Market]).unwrap_or(square);
+ let works = anchor(self, &[QuarterKind::Works, QuarterKind::Poor]).unwrap_or(gate);
+ let mut wanted = vec![
+ (ShopKind::Broker, gate),
+ (ShopKind::Shipyard, gate),
+ (ShopKind::Tavern, square),
+ (ShopKind::Doctor, homes),
+ (ShopKind::Scholar, scholar),
+ (ShopKind::Armoury, works),
+ ];
+ let mut quiet: Vec<Pos> = self
+ .t
+ .quarters
+ .iter()
+ .filter(|q| {
+ matches!(q.kind, QuarterKind::Poor | QuarterKind::Homes | QuarterKind::Works)
+ })
+ .map(|q| q.centre)
+ .collect();
+ // More taverns in bigger cities, each in a different quarter while
+ // there are quarters to go round.
+ quiet.shuffle(self.rng);
+ let extra = (self.size * 3.0).round() as usize;
+ wanted.extend(quiet.iter().cycle().take(extra).map(|&at| (ShopKind::Tavern, at)));
+ for (kind, near) in wanted {
+ let Some(b) = self.site_near(near, kind) else { continue };
+ let mut name = words::shop_name(kind, culture, self.rng);
+ for _ in 0..10 {
+ if !self.t.shops.iter().any(|s| s.name == name) {
+ break;
+ }
+ name = words::shop_name(kind, culture, self.rng);
+ }
+ let shop = self.t.shops.len();
+ self.t.shops.push(Shop {
+ kind,
+ name: name.clone(),
+ keeper: culture.person(self.rng),
+ building: b,
+ });
+ self.make(b, Kind::Shop(kind));
+ self.t.buildings[b].shop = Some(shop);
+ self.t.buildings[b].name = name;
+ }
+ }
+
+ /// Each gate has a guardhouse just inside it.
+ fn guardhouses(&mut self) {
+ for g in self.t.gates.clone() {
+ let near = (0..self.t.buildings.len())
+ .filter(|&b| {
+ let kind = self.t.buildings[b].kind;
+ !matches!(kind, Kind::Shop(_) | Kind::Palace | Kind::Temple | Kind::Guardhouse)
+ })
+ .min_by_key(|&b| distance2(self.t.buildings[b].door, g));
+ if let Some(b) = near.filter(|&b| distance2(self.t.buildings[b].door, g) < 40 * 40) {
+ self.make(b, Kind::Guardhouse);
+ }
+ }
+ }
+
+ /// Floods the ground outside the walls, but for a shore round them and
+ /// the landing field, and the road between. The roads to the other
+ /// gates run on out to sea.
+ fn sea(&mut self) {
+ let site = &self.t.site;
+ let (main, cx) = (self.main, self.wall.cx);
+ let shore = |p: Pos| {
+ let t = site.tile(p);
+ let west_road = t == Tile::Street && (p.y - main).abs() <= 1 && f64::from(p.x) < cx;
+ !matches!(t, Tile::Ground | Tile::Street) || west_road
+ };
+ // How far each bit of ground is from the shore, a step at a time.
+ let mut far = vec![i32::MAX; site.tiles.len()];
+ let mut queue = std::collections::VecDeque::new();
+ for p in site.positions() {
+ if shore(p) {
+ far[site.index(p)] = 0;
+ queue.push_back(p);
+ }
+ }
+ while let Some(p) = queue.pop_front() {
+ let d = far[site.index(p)];
+ for n in p.neighbours() {
+ if site.contains(n) && site.tile(n) == Tile::Ground && far[site.index(n)] > d + 1 {
+ far[site.index(n)] = d + 1;
+ queue.push_back(n);
+ }
+ }
+ }
+ // A beach two or three wide, wider and narrower along the coast.
+ let (a, b) = (self.rng.random_range(0.0..6.3), self.rng.random_range(0.0..6.3));
+ let flood: Vec<Pos> = site
+ .positions()
+ .filter(|&p| {
+ let (x, y) = (f64::from(p.x), f64::from(p.y));
+ let beach = 2.2 + 1.3 * (x * 0.23 + a).sin() * (y * 0.41 + b).sin();
+ site.tile(p) == Tile::Ground && f64::from(far[site.index(p)]) > beach
+ })
+ .collect();
+ for p in flood {
+ self.set(p, Tile::Sea);
+ }
+ }
+
+ /// Trees, stalls and lamps.
+ fn dress(&mut self) {
+ let all: Vec<Pos> = self.t.site.positions().collect();
+ let main = self.main;
+ // The Cant and the Drift leave pipes and scrap about; everyone plants.
+ let clutter = matches!(self.t.culture, Culture::Cant | Culture::Drift);
+ for &p in &all {
+ let tile = self.tile(p);
+ let roadside =
+ p.neighbours().any(|n| self.tile(n).paved() || self.tile(n) == Tile::Pad);
+ if tile == Tile::Ground && !roadside && (p.y - main).abs() > 1 {
+ if self.rng.random_bool(self.lay.wild) {
+ self.set(p, Tile::Wild);
+ }
+ } else if tile == Tile::Garden && !roadside {
+ let park = self.parks.iter().any(|r| r.contains(p));
+ let grounds = p.neighbours().any(|n| {
+ self.t.building_at(n).is_some_and(|b| {
+ matches!(self.t.buildings[b].kind, Kind::Palace | Kind::Temple)
+ })
+ });
+ let chance = if park {
+ 0.16
+ } else if grounds {
+ 0.3
+ } else {
+ 0.07
+ };
+ if self.rng.random_bool((chance * self.lay.trees).min(0.6)) {
+ let planted = !clutter || park || grounds || self.rng.random_bool(0.4);
+ self.set(p, if planted { Tile::Tree } else { Tile::Decor });
+ }
+ }
+ }
+ // Stalls along the top and bottom of the square, facing in, keeping
+ // clear of the streets that come into it.
+ let sq = self.square;
+ for (y, front) in [(sq.top + 1, 1), (sq.bottom - 1, -1)] {
+ let edge = if front == 1 { sq.top - 1 } else { sq.bottom + 1 };
+ let mut x = sq.left + 2;
+ while x + 2 <= sq.right - 2 {
+ let clear = (x - 1..=x + 3).all(|x| !self.tile(Pos::new(x, edge)).paved())
+ && (x..=x + 2).all(|x| self.tile(Pos::new(x, y)) == Tile::Square)
+ && (y - main).abs() > 1;
+ if clear && self.rng.random_bool(0.7) {
+ for dx in 0..3 {
+ self.set(Pos::new(x + dx, y), Tile::Stall);
+ }
+ self.stalls.push(Pos::new(x + 1, y + front));
+ x += 6;
+ } else {
+ x += 2;
+ }
+ }
+ }
+ for p in [
+ Pos::new(sq.left, sq.top),
+ Pos::new(sq.right, sq.top),
+ Pos::new(sq.left, sq.bottom),
+ Pos::new(sq.right, sq.bottom),
+ ] {
+ let doorstep = p.neighbours().any(|n| matches!(self.tile(n), Tile::Door { .. }));
+ if self.tile(p) == Tile::Square && !doorstep {
+ self.set(p, Tile::Lamp);
+ }
+ }
+ }
+
+ fn person(&mut self, pos: Pos, role: Role, stays: bool) {
+ let culture = self.t.culture;
+ let says = *words::lines(role, culture).choose(self.rng).unwrap_or(&"...");
+ self.t.site.people.push(Person { pos, role, name: None, says, stays });
+ }
+
+ fn people(&mut self) {
+ for at in self.stalls.clone() {
+ if self.t.site.free(at) {
+ self.person(at, Role::Stallholder, true);
+ }
+ }
+ // Guards just inside each gate, against the wall.
+ for g in self.t.gates.clone() {
+ let mut spots: Vec<Pos> = Rect::new(g.x - 3, g.y - 3, g.x + 3, g.y + 3)
+ .cells()
+ .filter(|&p| {
+ self.tile(p).outdoors()
+ && self.tile(p) != Tile::Gate
+ && self.t.site.free(p)
+ && p.neighbours().any(|n| self.tile(n) == Tile::CityWall)
+ && p.neighbours().all(|n| self.tile(n) != Tile::Gate)
+ })
+ .collect();
+ spots.shuffle(self.rng);
+ for p in spots.into_iter().take(2) {
+ self.person(p, Role::Guard, true);
+ }
+ }
+ let streets: Vec<Pos> = self.t.site.positions().filter(|&p| self.tile(p).paved()).collect();
+ let count = (streets.len() / 55).clamp(16, 180);
+ let crowd = (self.square.area() / 30) as usize;
+ let square: Vec<Pos> =
+ streets.iter().copied().filter(|&p| self.tile(p) == Tile::Square).collect();
+ let mut placed = 0;
+ let mut tries = 0;
+ while placed < count + crowd && tries < 20 * (count + crowd) {
+ tries += 1;
+ let pool = if placed < crowd { &square } else { &streets };
+ let Some(&p) = pool.choose(self.rng) else { break };
+ if !self.t.site.free(p) {
+ continue;
+ }
+ let q = self.quarter_kind(p);
+ let roll = self.rng.random_range(0.0..1.0);
+ let role = match q {
+ QuarterKind::Homes | QuarterKind::Poor if roll < 0.2 => Role::Child,
+ QuarterKind::Works | QuarterKind::Gate if roll < 0.25 => Role::Porter,
+ QuarterKind::Holy if roll < 0.3 => Role::Pilgrim,
+ _ => Role::Passerby,
+ };
+ self.person(p, role, false);
+ placed += 1;
+ }
+ }
+}
diff --git a/src/town/words.rs b/src/town/words.rs
new file mode 100644
index 0000000..251f2b2
--- /dev/null
+++ b/src/town/words.rs
@@ -0,0 +1,633 @@
+//! What things in a city are called, and what people there say.
+//!
+//! Names use the same templates as `names`: `{w}` is a new word in the
+//! culture's tongue, `{n}` a number, `{r}` a Roman numeral.
+
+use super::{Kind, Land, Pond, Purpose, QuarterKind, Role, ShopKind, Thing, Tile};
+use crate::names::{Culture, pick};
+use crate::seed::Rng;
+use crate::space::system::Terrain;
+
+fn fill(culture: Culture, templates: &'static [&'static str], rng: &mut Rng) -> String {
+ culture.expand(pick(templates, rng), rng)
+}
+
+pub fn quarter_name(culture: Culture, kind: QuarterKind, rng: &mut Rng) -> String {
+ use QuarterKind::*;
+ let templates: &'static [&'static str] = match (culture, kind) {
+ (Culture::Qaari, Market) => &["the {w} Souk", "the Great Souk", "the Spice Souk"],
+ (Culture::Qaari, Gate) => &["the Caravan Yards", "the {w} Caravanserai", "the Gate of {w}"],
+ (Culture::Qaari, Homes) => &["the {w} Quarter", "the Lanes of {w}", "the Old Wells"],
+ (Culture::Qaari, Poor) => &["the Lower Wells", "the Dust Quarter", "the {w} Warren"],
+ (Culture::Qaari, Grand) => &["the Rise of {w}", "the Shaded Heights", "the Palace Rise"],
+ (Culture::Qaari, Holy) => {
+ &["the Shrine Steps", "the Deep Well Quarter", "the {w} Sanctuary"]
+ }
+ (Culture::Qaari, Works) => {
+ &["the Dyers' Lanes", "the Glassblowers' Quarter", "the Tanneries"]
+ }
+ (Culture::Qaari, Gardens) => &["the Palm Gardens", "the {w} Oasis", "the Date Groves"],
+ (Culture::Cant, Market) => &["the Exchange Floor", "Barter Deck {n}", "the {w} Market"],
+ (Culture::Cant, Gate) => &["the Intake", "Gate Sector {n}", "the Receiving Yards"],
+ (Culture::Cant, Homes) => &["Hab Sector {n}", "the {w} Habs", "Dormitory Row {r}"],
+ (Culture::Cant, Poor) => &["the Undercroft", "the Sump", "Sector {n}-Low"],
+ (Culture::Cant, Grand) => &["the Primarch's Tier", "the Upper Spire", "the {w} Tier"],
+ (Culture::Cant, Holy) => &["the Litany Steps", "the Engine Precinct", "the Fane Sector"],
+ (Culture::Cant, Works) => &["the Foundry Ward", "Pipe Row", "the {w} Works"],
+ (Culture::Cant, Gardens) => {
+ &["the Coolant Pools", "the Fungus Vaults", "the Moss Terraces"]
+ }
+ (Culture::Houses, Market) => &["the {w} Market", "the Marble Exchange", "the Silk Arcade"],
+ (Culture::Houses, Gate) => {
+ &["the Gate of {w}", "the Customs Quarter", "the Carriage Yards"]
+ }
+ (Culture::Houses, Homes) => &["the {w} Quarter", "Lantern Row", "the {w} Walk"],
+ (Culture::Houses, Poor) => &["the Stews", "Tallow Lane", "the {w} Rookery"],
+ (Culture::Houses, Grand) => &["the Archon's Hill", "the {w} Heights", "the Court Quarter"],
+ (Culture::Houses, Holy) => {
+ &["the Cathedral Close", "the {w} Cloisters", "the Choir Quarter"]
+ }
+ (Culture::Houses, Works) => &["the Perfumers' Quarter", "the Ateliers", "Glass Street"],
+ (Culture::Houses, Gardens) => &["the {w} Gardens", "the Pleasure Gardens", "the Orangery"],
+ (Culture::Drift, Market) => &["the Swap", "the {w} Bazaar", "the Hanging Market"],
+ (Culture::Drift, Gate) => &["the Moorings", "the {w} Berths", "the Dockside"],
+ (Culture::Drift, Homes) => &["the Bunks", "{w} Row", "the Hull Quarter"],
+ (Culture::Drift, Poor) => &["the Scrap", "the Leaky End", "the {w} Tangle"],
+ (Culture::Drift, Grand) => &["Captains' Row", "the High Masts", "the {w} Deck"],
+ (Culture::Drift, Holy) => &["the Lantern Quarter", "the Rememberings", "the {w} Vigil"],
+ (Culture::Drift, Works) => &["the Splice", "the Sail Lofts", "the {w} Yards"],
+ (Culture::Drift, Gardens) => {
+ &["the Green Tanks", "the {w} Hydroponics", "the Garden Hulls"]
+ }
+ };
+ fill(culture, templates, rng)
+}
+
+/// Who works in a workshop.
+fn trades(culture: Culture) -> &'static [&'static str] {
+ match culture {
+ Culture::Qaari => &["weaver", "potter", "glassblower", "dyer", "coppersmith", "rugmaker"],
+ Culture::Cant => &["welder", "wire-drawer", "gear-cutter", "plater", "cogwright"],
+ Culture::Houses => &["perfumer", "jeweller", "tailor", "luthier", "mask-maker", "glazier"],
+ Culture::Drift => &["sailmaker", "splicer", "tinker", "chartmaker", "ropemaker"],
+ }
+}
+
+/// What a building is called: "a house", "House Vastre", "the Qasr Khaseem".
+pub fn building_name(kind: Kind, culture: Culture, rng: &mut Rng) -> String {
+ use Culture::*;
+ let templates: &'static [&'static str] = match (kind, culture) {
+ (Kind::Shop(shop), _) => return shop_name(shop, culture, rng),
+ (Kind::House, _) => &["a house"],
+ (Kind::Tenement, Qaari) => &["a courtyard tenement"],
+ (Kind::Tenement, Cant) => &["a hab-block"],
+ (Kind::Tenement, Houses) => &["a lodging house"],
+ (Kind::Tenement, Drift) => &["a bunkhouse"],
+ (Kind::Manor, Qaari) => &["the House of {w}", "the {w} Mansion"],
+ (Kind::Manor, Cant) => &["the {w} Habitation", "Residence {w}-{n}"],
+ (Kind::Manor, Houses) => &["House {w}", "the {w} Townhouse"],
+ (Kind::Manor, Drift) => &["the {w} Berth", "Captain {w}'s"],
+ (Kind::Palace, Qaari) => &["the Qasr {w}"],
+ (Kind::Palace, Cant) => &["the Primarch's Spire"],
+ (Kind::Palace, Houses) => &["the Archon's Palace"],
+ (Kind::Palace, Drift) => &["the Speaker's Hulk"],
+ (Kind::Temple, Qaari) => &["the Deep Well of {w}"],
+ (Kind::Temple, Cant) => &["the Fane of the Engine"],
+ (Kind::Temple, Houses) => &["the Basilica of {w}"],
+ (Kind::Temple, Drift) => &["the Lantern of the Lost"],
+ (Kind::Shrine, Qaari) => &["a well-shrine"],
+ (Kind::Shrine, Cant) => &["a litany-chapel"],
+ (Kind::Shrine, Houses) => &["a chapel"],
+ (Kind::Shrine, Drift) => &["a waystone shrine"],
+ (Kind::Workshop, _) => {
+ let trade = pick(trades(culture), rng);
+ return format!("a {trade}'s workshop");
+ }
+ (Kind::Warehouse, Qaari) => &["a caravan store"],
+ (Kind::Warehouse, Cant) => &["a depot"],
+ (Kind::Warehouse, Houses) => &["a warehouse"],
+ (Kind::Warehouse, Drift) => &["a cargo shed"],
+ (Kind::Bathhouse, Qaari) => &["the {w} Baths", "a bathhouse"],
+ (Kind::Bathhouse, _) => &["the {w} Baths", "a bathhouse"],
+ (Kind::Guardhouse, Qaari) => &["a gatehouse"],
+ (Kind::Guardhouse, Cant) => &["an enforcer post"],
+ (Kind::Guardhouse, Houses) => &["a guardhouse"],
+ (Kind::Guardhouse, Drift) => &["the watch-house"],
+ };
+ fill(culture, templates, rng)
+}
+
+pub fn shop_name(kind: ShopKind, culture: Culture, rng: &mut Rng) -> String {
+ use Culture::*;
+ use ShopKind::*;
+ let templates: &'static [&'static str] = match (kind, culture) {
+ (Broker, Qaari) => &["the {w} Assay", "the Ore Souk of {w}"],
+ (Broker, Cant) => &["the Ore-Tithe of {w}", "Assay Station {n}"],
+ (Broker, Houses) => &["the {w} Exchange", "the Royal Assayer"],
+ (Broker, Drift) => &["{w}'s Scales", "the Weigh-In"],
+ (Doctor, Qaari) => &["the House of Salves", "the {w} Infirmary"],
+ (Doctor, Cant) => &["the Mending Shrine", "Repair Bay {n}"],
+ (Doctor, Houses) => &["Physician {w}'s", "the {w} Hospice"],
+ (Doctor, Drift) => &["the Patchwright", "the Sick Bay"],
+ (Scholar, Qaari) => &["the Sand Archive", "the House of {w}'s Scrolls"],
+ (Scholar, Cant) => &["the Index of Relics", "Archive {r}"],
+ (Scholar, Houses) => &["the {w} Athenaeum", "the {w} Collection"],
+ (Scholar, Drift) => &["the Rememberer", "the Log-Keeper"],
+ (Tavern, Qaari) => &["the {w} Cistern", "the Thirsty Dune", "the {w} Tea House"],
+ (Tavern, Cant) => &["the Oil Hall", "the Burnt Gasket", "Refreshment Unit {n}"],
+ (Tavern, Houses) => &["the {w} Salon", "the Gilded {w}", "the Masquerade"],
+ (Tavern, Drift) => &["the Slack Line", "the {w} Mooring", "the Last Burn"],
+ (Shipyard, Qaari) => &["the Caravan-Wrights", "the {w} Yards"],
+ (Shipyard, Cant) => &["Hull Foundry {w}", "Drydock {n}"],
+ (Shipyard, Houses) => &["the Royal Yards", "the {w} Shipwrights"],
+ (Shipyard, Drift) => &["the Splicers", "{w}'s Slip"],
+ (Armoury, Qaari) => &["the {w} Blade-Souk", "the House of Edges"],
+ (Armoury, Cant) => &["Armoury {r}", "the Arc-Smithy of {w}"],
+ (Armoury, Houses) => &["the {w} Armourers", "the Duellists' Gallery"],
+ (Armoury, Drift) => &["{w}'s Iron", "the Gun Locker"],
+ };
+ fill(culture, templates, rng)
+}
+
+/// What a ruler is called.
+pub fn ruler_title(culture: Culture) -> &'static str {
+ match culture {
+ Culture::Qaari => "the Well-Warden",
+ Culture::Cant => "the Primarch",
+ Culture::Houses => "the Archon",
+ Culture::Drift => "the Speaker",
+ }
+}
+
+/// "A guard", for people without a name.
+pub fn role_name(role: Role, culture: Culture) -> &'static str {
+ match role {
+ Role::Passerby => "A passer-by",
+ Role::Child => "A child",
+ Role::Porter => "A porter",
+ Role::Pilgrim => "A pilgrim",
+ Role::Stallholder => "A stallholder",
+ Role::Guard => match culture {
+ Culture::Cant => "An enforcer",
+ _ => "A guard",
+ },
+ Role::Keeper(_) => "The keeper",
+ Role::Resident => "Someone who lives here",
+ Role::Servant => "A servant",
+ Role::Ruler => ruler_title(culture),
+ Role::Courtier => "A courtier",
+ Role::Priest => match culture {
+ Culture::Qaari => "A well-priest",
+ Culture::Cant => "A tech-cantor",
+ Culture::Houses => "A priest",
+ Culture::Drift => "A lamplighter",
+ },
+ Role::Worshipper => "A worshipper",
+ Role::Worker => "A worker",
+ Role::Patron => "A drinker",
+ Role::Patient => "A patient",
+ Role::Bather => "A bather",
+ Role::Watchman => "A watchman",
+ Role::Reader => "A reader",
+ }
+}
+
+/// What someone says when you bump into them.
+pub fn lines(role: Role, culture: Culture) -> &'static [&'static str] {
+ use Culture::*;
+ match (role, culture) {
+ (Role::Passerby, _) => chatter(culture),
+ (Role::Stallholder, _) => cries(culture),
+ (Role::Child, Qaari) => &[
+ "Is that your ship? Is it faster than a sandstorm?",
+ "I can hold my breath through a whole dust-wind.",
+ "My brother says wrecks have ghosts in them.",
+ ],
+ (Role::Child, Cant) => &[
+ "I am in my apprentice cycle. I'm not supposed to talk to you.",
+ "Do your engines pray? Ours do.",
+ "I counted all the bolts on the wall. There are four thousand and six.",
+ ],
+ (Role::Child, Houses) => &[
+ "Mother says spacers are common. Are you common?",
+ "I'm going to be an Archon when I grow up. Or a pirate.",
+ "Bet you can't catch me!",
+ ],
+ (Role::Child, Drift) => &[
+ "Have you been to the stars? Which one's the best?",
+ "I was born on a ship, you know. I'm only visiting.",
+ "Tag! You're it!",
+ ],
+ (Role::Porter, _) => &[
+ "Mind your back! Heavy load!",
+ "Two more crates and I can eat.",
+ "Out of the way, it's on my head, not yours.",
+ "They pay by the load, not by the step. Shift.",
+ ],
+ (Role::Pilgrim, _) => &[
+ "I've walked forty days to get here.",
+ "They say a prayer here is heard twice.",
+ "Have you come to pray too? You don't look it.",
+ "I promised my mother I'd come. She's been dead nine years.",
+ ],
+ (Role::Guard, Qaari) => &[
+ "Keep your hands where the sun can see them.",
+ "No blades drawn in the souk, offworlder.",
+ "The gate shuts when the third wind blows. Be in, or be out.",
+ ],
+ (Role::Guard, Cant) => &[
+ "Halt. Proceed. You were not stopped.",
+ "Unit. Your papers are in order. Probably.",
+ "Patrol cycle four hundred and twelve. All nominal.",
+ ],
+ (Role::Guard, Houses) => &[
+ "Move along, spacer. Nothing to see but your betters.",
+ "The Archon's peace is kept here.",
+ "Draw steel in this city and you'll leave it lighter.",
+ ],
+ (Role::Guard, Drift) => &[
+ "Easy, spacer. Nobody's looking for trouble.",
+ "Watch shift. Twelve hours of nothing, mostly.",
+ "If anyone asks, I was awake.",
+ ],
+ (Role::Keeper(_), _) => &["What can I do for you?"],
+ (Role::Resident, Qaari) => &[
+ "You'll take water? Everyone who comes in takes water.",
+ "My grandmother built this house with her own hands.",
+ "Shut the door behind you, the sand gets in.",
+ "A guest! Sit, sit. Not there. There.",
+ ],
+ (Role::Resident, Cant) => &[
+ "Unauthorised presence in a hab-unit. I'll allow it.",
+ "The walls hum at night. It's comforting.",
+ "Visitors are logged. You have been logged.",
+ "My shift starts in an hour. Be brief.",
+ ],
+ (Role::Resident, Houses) => &[
+ "We weren't expecting callers. Do forgive the state of things.",
+ "Mind the portraits. Every one of them is watching you.",
+ "If you're selling something, we already have two.",
+ "Oh! A spacer. How thrilling. Please don't touch anything.",
+ ],
+ (Role::Resident, Drift) => &[
+ "Come in, come in. Nobody locks a door in the Drift.",
+ "It's small, but it doesn't move. I'm still getting used to that.",
+ "Got any news from out there?",
+ "Sit anywhere. Everything's somebody's, nothing's anyone's.",
+ ],
+ (Role::Servant, _) => &[
+ "The household is not receiving.",
+ "I'm to polish the silver before the bell.",
+ "You didn't see me sitting down.",
+ "Wipe your feet. Please. I just did that floor.",
+ ],
+ (Role::Ruler, Qaari) => &[
+ "Every drop in this city is counted, offworlder, and I count them.",
+ "Sit in my shade. Few strangers are offered it.",
+ "The deep erg gives and the deep erg takes. I only keep the ledger.",
+ ],
+ (Role::Ruler, Cant) => &[
+ "The Engine wills, and I implement. State your function.",
+ "You are a variable. I dislike variables.",
+ "Efficiency is devotion. You are wasting both.",
+ ],
+ (Role::Ruler, Houses) => &[
+ "We so rarely receive spacers. How novel.",
+ "Kneel, if you know how. No? Then bow. No? Then stand there.",
+ "My ancestors held this city for eleven hundred years. I intend to beat that.",
+ ],
+ (Role::Ruler, Drift) => &[
+ "I don't rule. Someone has to hold the tiller, that's all.",
+ "The council meets when it meets. Sit, if you like.",
+ "Every ship that docks here is family until it isn't.",
+ ],
+ (Role::Courtier, _) => &[
+ "Petitions are heard at noon. It is never noon.",
+ "I've been waiting for an audience since the last eclipse.",
+ "Don't stand there. That's where the last ambassador stood.",
+ "Have you heard who dined with whom? No? Then what use are you?",
+ ],
+ (Role::Priest, Qaari) => &[
+ "The deep well answers every question with silence. Silence is an answer.",
+ "Drink, and remember who dug the well before you.",
+ "Water remembers. That is why we pray over it.",
+ ],
+ (Role::Priest, Cant) => &[
+ "Blessed be the torque. Blessed be the tolerance.",
+ "Every machine is a prayer that works.",
+ "The Engine turns. Will you turn with it?",
+ ],
+ (Role::Priest, Houses) => &[
+ "The saints loved this city once. We are trying to remind them.",
+ "Light a candle, if you have a coin. Light two, if you have a sin.",
+ "Even the Archon kneels here. Briefly.",
+ ],
+ (Role::Priest, Drift) => &[
+ "Every lantern here is someone who didn't come home.",
+ "We remember the lost so the void doesn't have to.",
+ "If you board wrecks, light one for whoever you find.",
+ ],
+ (Role::Worshipper, _) => &[
+ "Shh.",
+ "...and keep the void from the hull, and the hull from the void...",
+ "I'm praying. Go away. Kindly.",
+ "Pray with us, or step softly.",
+ ],
+ (Role::Worker, _) => &[
+ "Mind the edge, it's sharp.",
+ "Commissions take a month. Two if you're rude.",
+ "Every piece made by hand. My hands. Look at them.",
+ "Pass me that. No, the other one.",
+ ],
+ (Role::Patron, _) => &[
+ "Buy a round and I'll tell you a story. Two rounds, a true one.",
+ "This is my stool. It knows me.",
+ "The wrecks out past the belt? Cursed, the lot of them.",
+ "One more and I'll go home. I said that last night too.",
+ "You've got the look of someone who boards wrecks.",
+ ],
+ (Role::Patient, _) => &[
+ "Don't mind me. I'm dying, slightly.",
+ "The doctor says I'll mend. The doctor says that to everyone.",
+ "Got bitten by something aboard a wreck. Don't ask what.",
+ ],
+ (Role::Bather, _) => &[
+ "Ah. Heaven is hot water.",
+ "Close the door, you're letting the steam out.",
+ "Best thing about a planet: baths.",
+ ],
+ (Role::Watchman, _) => &[
+ "Nothing in here but crates. And me.",
+ "If you're thinking of stealing, think harder.",
+ "I count the crates every hour. Still forty-one.",
+ ],
+ (Role::Reader, _) => &[
+ "Shh. Some of us are reading.",
+ "Did you know the old empire had nine words for vacuum?",
+ "I've been on this page for three days.",
+ ],
+ }
+}
+
+/// What passers-by say when you bump into them.
+pub fn chatter(culture: Culture) -> &'static [&'static str] {
+ match culture {
+ Culture::Qaari => &[
+ "Water and shade to you, offworlder.",
+ "The caravan from the deep erg is three days late. Three!",
+ "Don't drink from the public cistern after dusk. Old law.",
+ "My cousin crews an ore hauler. He says the belt sings at night.",
+ "You smell of ship. Go to the tavern; everyone does.",
+ "The glass storms took the east road again.",
+ ],
+ Culture::Cant => &[
+ "Unit. Your hull is unblessed. Have it seen to.",
+ "The litany runs at the third bell. Attendance is noted.",
+ "Every bolt remembers the hand that turned it.",
+ "Salvage is sacrament. Do not waste it.",
+ "I was a pump-servitor before my promotion. I miss the pumps.",
+ "Do not touch the pipes. The pipes are listening.",
+ ],
+ Culture::Houses => &[
+ "Mind your boots on the marble, spacer.",
+ "A House is hosting again. Nobody was invited, so everybody will go.",
+ "They say the Archon's heir has taken up mining. Imagine.",
+ "The fountain was a gift. Everything here was a gift. Everything has a price.",
+ "Do you dance? No? Pity.",
+ "Never bow lower than the person you're bowing to.",
+ ],
+ Culture::Drift => &[
+ "Hey, spacer. Long burn?",
+ "Planets make me dizzy. Too much down.",
+ "Trade you a song for a ration bar.",
+ "My mother was born between stars. So was I. It's strange, being here.",
+ "Somebody's always leaving. That's the Drift.",
+ "If you see a blue hull with a broken mast, that's my aunt. Wave.",
+ ],
+ }
+}
+
+/// What stallholders call out.
+pub fn cries(culture: Culture) -> &'static [&'static str] {
+ match culture {
+ Culture::Qaari => &[
+ "Dates! Sun-dates, sweet as a promise!",
+ "Spiced locust, two for a ¤!",
+ "Rugs! Woven by hand, by grandmother's hand!",
+ "Water-skins, never leaked, never once!",
+ ],
+ Culture::Cant => &[
+ "Bearings, blessed and greased!",
+ "Lubricant, consecrated this very morning!",
+ "Prayer-wire by the metre!",
+ "Spare parts for spare souls!",
+ ],
+ Culture::Houses => &[
+ "Candied petals, fit for a court!",
+ "Silks from the Gardens!",
+ "Scent of the old capital, one drop a ¤!",
+ "Masks for the season's balls!",
+ ],
+ Culture::Drift => &[
+ "Patches, hull or heart!",
+ "Songs, fresh from the void!",
+ "Ration bars, hardly expired!",
+ "Charts of places that might still be there!",
+ ],
+ }
+}
+
+pub fn room_name(purpose: Purpose, culture: Culture) -> &'static str {
+ use Purpose::*;
+ match purpose {
+ Living => "the front room",
+ Bedroom => "a bedroom",
+ Kitchen => "the kitchen",
+ Store => "a storeroom",
+ Lodging => "someone's room",
+ Taproom => "the taproom",
+ Parlour => "the back room",
+ Cellar => "the cellar",
+ Counter => "the shop",
+ Ward => "the ward",
+ Stacks => "the stacks",
+ Assay => "the assay room",
+ Yard => "the yard",
+ Smithy => "the smithy",
+ Hall => "the hall",
+ Dining => "the dining room",
+ Library => "the library",
+ Bedchamber => "a bedchamber",
+ Gallery => "the gallery",
+ Throne => "the throne room",
+ Council => "the council chamber",
+ Treasury => "the treasury",
+ Nave => match culture {
+ Culture::Qaari => "the well-court",
+ Culture::Cant => "the engine hall",
+ Culture::Houses => "the nave",
+ Culture::Drift => "the lantern hall",
+ },
+ Vestry => "the vestry",
+ Cell => "a cell",
+ Shrine => "the shrine",
+ Workroom => "the workshop",
+ Storehouse => "the store",
+ Office => "the office",
+ Changing => "the changing room",
+ Baths => "the baths",
+ Steam => "the steam room",
+ Guardroom => "the guardroom",
+ Bunks => "the bunk room",
+ }
+}
+
+/// What bumping into a piece of furniture tells you.
+pub fn thing_about(thing: Thing, culture: Culture) -> &'static str {
+ use Culture::*;
+ use Thing::*;
+ match (thing, culture) {
+ (Table, _) => "A table, ringed with old cups.",
+ (Chair | Pew | Bench | Throne, _) => "A seat.",
+ (Bed, Qaari) => "A sleeping mat on a low frame, piled with rugs.",
+ (Bed, Cant) => "A cot of welded mesh. Exactly as comfortable as required.",
+ (Bed, Houses) => "A canopied bed, its curtains heavy with dust.",
+ (Bed, Drift) => "A hammock-bed, rigged as if the floor might go away.",
+ (Hearth, Cant) => "A heating coil glows behind a grille.",
+ (Hearth, _) => "A fire crackles in the hearth.",
+ (Stove, _) => "A stove, still warm, smelling of whatever was cooked last.",
+ (Shelf, _) => "Shelves crammed with books, jars and keepsakes.",
+ (Chest, _) => "A chest. Locked, and none of your business.",
+ (Crate, _) => "Crates, stencilled with marks you can't read.",
+ (Barrel, _) => "A barrel. It sloshes.",
+ (Altar, Qaari) => "The sacred well. The water is so deep it's black.",
+ (Altar, Cant) => "The Engine: a vast machine that only turns. It hums in your teeth.",
+ (Altar, Houses) => "An altar of white stone, gold-leafed, crowded with candles.",
+ (Altar, Drift) => "A lantern-mast, a lantern hung on it for everyone lost in the void.",
+ (Pillar, _) => "A pillar, carved all the way up.",
+ (Statue, Qaari) => "A statue of a caravan-master, worn smooth by sandstorms.",
+ (Statue, Cant) => "A statue of a Fabricant, its face a smooth steel plate.",
+ (Statue, Houses) => "A marble statue of someone important, looking down on you.",
+ (Statue, Drift) => "A figurehead, salvaged from some old ship.",
+ (Plant, Qaari) => "A potted palm, carefully watered.",
+ (Plant, _) => "A potted plant, carefully watered.",
+ (Bath, _) => "Steaming water. It smells of minerals.",
+ (Anvil, _) => "An anvil, bright where the hammer falls.",
+ (Forge, _) => "A forge, roaring. The heat pushes you back.",
+ (Loom, _) => "A loom, strung with half a rug.",
+ (Workbench, _) => "A workbench, scattered with tools and offcuts.",
+ (Rack, _) => "A rack of pikes and stun-staves.",
+ (Lamp, Cant) => "A glow-tube on a stand, flickering.",
+ (Lamp, _) => "A lamp on a tall stand.",
+ (Engine, _) => "Half a ship's engine, opened up like a patient.",
+ }
+}
+
+/// What bumping into something in the street tells you, if anything, in
+/// a city of `culture` on `land`. For `Tile::Wild`, see `wild_about`.
+pub fn tile_about(tile: Tile, culture: Culture, land: Land) -> Option<&'static str> {
+ use Culture::*;
+ Some(match (tile, culture) {
+ (Tile::Centrepiece, Qaari) => "The cistern's water is cool, dark and very still.",
+ (Tile::Centrepiece, Cant) => "The furnace roars. Prayers are welded to its flue.",
+ (Tile::Centrepiece, Houses) => {
+ "Coins glint at the bottom of the fountain. Wishes, or bribes."
+ }
+ (Tile::Centrepiece, Drift) => {
+ "A mast of welded hull-plate, hung with a hundred little bells."
+ }
+ (Tile::Statue, _) => thing_about(Thing::Statue, culture),
+ (Tile::Well, Qaari) => "A covered well, padlocked. Water isn't free here.",
+ (Tile::Well, Cant) => "A coolant tap, sealed with a litany-tag.",
+ (Tile::Well, Houses) => "A little fountain, a stone fish spitting into a basin.",
+ (Tile::Well, Drift) => "A water tank on legs, with a cup on a chain.",
+ (Tile::Water, _) => pond_about(land),
+ (Tile::Sea, _) => "The sea, grey-green and restless, all the way to the sky.",
+ (Tile::Tree, _) => tree_about(culture, land.terrain),
+ (Tile::Lamp, Cant) => "A glow-tube on a post, buzzing.",
+ (Tile::Lamp, _) => "A lamp post.",
+ (Tile::Decor, Drift) => "A heap of salvage, sorted by someone, for some reason.",
+ (Tile::Decor, _) => "A tangle of pipes, warm to the touch.",
+ (Tile::Roof, _) => return None,
+ _ => return None,
+ })
+}
+
+fn pond_about(land: Land) -> &'static str {
+ match (land.pond(), land.terrain) {
+ (Pond::Water, Terrain::Desert) => "A cistern pool, shaded and very still.",
+ (Pond::Water, _) => "A pond. Fish, or something like them, move under the surface.",
+ (Pond::Ice, Terrain::Ice) => "A pond frozen to the bottom, clear as glass.",
+ (Pond::Ice, _) => "A frozen pond. Something dark moves slowly under the ice.",
+ (Pond::Dry, _) => "A pond, dried to cracked mud. Someone's raked the cracks.",
+ (Pond::Lava, _) => "A pool of molten rock behind an iron rail. The heat is ferocious.",
+ }
+}
+
+fn tree_about(culture: Culture, terrain: Terrain) -> &'static str {
+ match (terrain, culture) {
+ (Terrain::Desert, Culture::Qaari) => "A date palm, rattling in the hot wind.",
+ (Terrain::Desert, _) => "A date palm, its fronds grey with dust.",
+ (Terrain::Ice, _) => "A glass-pine. Its needles chime when the wind moves them.",
+ (Terrain::Ocean | Terrain::Jungle, Culture::Houses) => {
+ "A clipped yew, shaped like a bird. Something keeps trying to unclip it."
+ }
+ (Terrain::Ocean, _) => "A salt-pine, leaning away from the sea wind.",
+ (Terrain::Jungle, _) => "A strangler fig, wound round what used to be another tree.",
+ (Terrain::Barren | Terrain::Clouds, _) => {
+ "A spire of violet crystal, taller than you. It's growing, very slowly."
+ }
+ (Terrain::Forge, _) => "A fungus stalk as tall as a lamp post, warm and faintly pulsing.",
+ }
+}
+
+/// What bumping into the `sort`th sort of thing outside the walls tells
+/// you (see `Town::wild_sort`).
+pub fn wild_about(terrain: Terrain, sort: usize) -> &'static str {
+ let [first, second] = match terrain {
+ Terrain::Desert => [
+ "A cactus, fat with stored water and bristling with spines.",
+ "A boulder, scoured smooth by the sand.",
+ ],
+ Terrain::Ice => [
+ "A spire of blue ice, taller than you and slowly leaning.",
+ "A boulder, glazed with ice.",
+ ],
+ Terrain::Ocean => {
+ ["Black rock, slick with spray.", "A heap of kelp, washed up and drying. It smells."]
+ }
+ Terrain::Jungle => [
+ "Trees, crowded so close you can't push between them.",
+ "A wall of ferns and creepers, alive with clicking.",
+ ],
+ Terrain::Barren | Terrain::Clouds => [
+ "A crag of grey rock, crusted with rust-red lichen.",
+ "A cluster of violet crystals, knee-high.",
+ ],
+ Terrain::Forge => {
+ ["A heap of black basalt, still warm.", "A vent, breathing hot, sulphurous air."]
+ }
+ };
+ if sort == 0 { first } else { second }
+}
+
+/// What the road out of a gate leads to.
+pub fn beyond(terrain: Terrain) -> &'static str {
+ match terrain {
+ Terrain::Desert => "The road runs on into the dunes. There's nothing out there for you.",
+ Terrain::Ice => {
+ "The road runs on across the snowfields. There's nothing out there for you."
+ }
+ Terrain::Ocean => {
+ "The causeway runs on out to sea, to the fishing piers. There's nothing out there \
+ for you."
+ }
+ Terrain::Jungle => "The road runs on under the canopy. There's nothing out there for you.",
+ Terrain::Barren | Terrain::Clouds => {
+ "The road runs on across the grey dust. There's nothing out there for you."
+ }
+ Terrain::Forge => {
+ "The road runs on between smoking lava fields. There's nothing out there for you."
+ }
+ }
+}
diff --git a/src/trade.rs b/src/trade.rs
new file mode 100644
index 0000000..cfd92bf
--- /dev/null
+++ b/src/trade.rs
@@ -0,0 +1,639 @@
+//! What things cost and what they fetch: ore and salvage at markets,
+//! medpatches, scholars' fees and offers, weapons and armour at an
+//! armourer's (and mending it), ship
+//! fittings, bigger packs, fuel and tows, and the talk you can buy in a
+//! tavern. Prices vary from place to place but stay the same at any one
+//! place, visit after visit.
+
+use std::collections::HashSet;
+
+use rand::RngExt;
+use rand::seq::SliceRandom;
+use serde::{Deserialize, Serialize};
+
+use crate::app::{Hold, Kept};
+use crate::dungeon::armour::{self, Armor};
+use crate::dungeon::arms::{self, Weapon};
+use crate::items::{Item, Salvage};
+use crate::names::{Artifact, Culture};
+use crate::scenes::sky::hash01;
+use crate::seed;
+use crate::space::galaxy::Galaxy;
+use crate::space::locale::{ORES, Ore};
+use crate::space::system::{System, SystemKind};
+
+/// A number from `low` to `high`, fixed for this place and `what`.
+fn local(system: &System, place: usize, what: &str, low: f64, high: f64) -> f64 {
+ let k = hash01(seed::derive(system.seed, what, place as u64), 0, 0);
+ low + (high - low) * k
+}
+
+/// What each ore fetches at a place: its value, give or take.
+pub fn ore_prices(system: &System, place: usize) -> Vec<(&'static Ore, u32)> {
+ let market = seed::derive(system.seed, "market", place as u64);
+ ORES.iter()
+ .enumerate()
+ .map(|(i, ore)| {
+ let k = 0.7 + 0.7 * hash01(market, i as i64, 0);
+ (ore, (f64::from(ore.value) * k).round().max(1.0) as u32)
+ })
+ .collect()
+}
+
+/// Sells all the ore in the hold at these prices. Returns the tons sold and
+/// what they fetched, or `None` if there was nothing to sell.
+pub fn sell_ore(hold: &mut Hold, prices: &[(&'static Ore, u32)]) -> Option<(u32, u32)> {
+ let tons = hold.ore_tons();
+ if tons == 0 {
+ return None;
+ }
+ let price = |name: &str| prices.iter().find(|(o, _)| o.name == name).map_or(0, |(_, p)| *p);
+ let earned = hold.ore.iter().map(|(name, t)| t * price(name)).sum();
+ hold.ore.clear();
+ hold.earn(earned);
+ Some((tons, earned))
+}
+
+/// What brokers pay for salvage, against what it's worth: from this to this,
+/// depending on the place.
+const SCRAP: (f64, f64) = (0.75, 1.2);
+/// What armourers charge for arms and armour, against what they're worth.
+const ARMS_SOLD: (f64, f64) = (0.9, 1.2);
+/// What armourers pay, against what they charge.
+const ARMS_BOUGHT: (f64, f64) = (0.45, 0.55);
+/// What scholars pay for an artifact, against what it's worth.
+const COLLECTED: (f64, f64) = (0.8, 1.25);
+
+fn middle((low, high): (f64, f64)) -> f64 {
+ (low + high) / 2.0
+}
+
+/// About what one of `item` would fetch from whoever buys that sort of
+/// thing, at a place in the middle of the range: an armourer for arms and
+/// armour, a broker for salvage, a scholar for an artifact once one's told
+/// you what it is. `None` for what no one buys, and for an artifact no one
+/// has named yet.
+pub fn typical_offer(item: &Item) -> Option<u32> {
+ let arms = middle(ARMS_SOLD) * middle(ARMS_BOUGHT);
+ let worth = match item {
+ Item::Weapon(w) => f64::from(arms::value(w)) * arms,
+ Item::Armor(a) => armour::worth(a) * arms,
+ Item::Salvage(s) => f64::from(s.value) * middle(SCRAP),
+ Item::Artifact(a) if a.identified => f64::from(artifact_value(a)) * middle(COLLECTED),
+ _ => return None,
+ };
+ Some(worth.round().max(1.0) as u32)
+}
+
+/// What a broker here pays for one piece of salvage: what it's worth, give
+/// or take.
+pub fn salvage_offer(system: &System, place: usize, salvage: &Salvage) -> u32 {
+ let k = local(system, place, "scrap", SCRAP.0, SCRAP.1);
+ (f64::from(salvage.value) * k).round().max(1.0) as u32
+}
+
+/// Every stack of salvage you have, on you and aboard, with what all of it
+/// would fetch here.
+pub fn salvage_kept(hold: &Hold, system: &System, place: usize) -> Vec<(Kept, u32)> {
+ hold.kept()
+ .filter_map(|(at, s)| match &s.item {
+ Item::Salvage(v) => Some((at, salvage_offer(system, place, v) * s.count)),
+ _ => None,
+ })
+ .collect()
+}
+
+/// Sells the salvage kept `at` for what a broker here pays. Returns what it
+/// was and what it fetched, or `None` if there's no salvage there.
+pub fn sell_salvage(
+ hold: &mut Hold,
+ system: &System,
+ place: usize,
+ at: Kept,
+) -> Option<(String, u32)> {
+ let stack = hold.kept_mut(at)?;
+ let Item::Salvage(v) = &stack.item else { return None };
+ let earned = salvage_offer(system, place, v) * stack.count;
+ let count = stack.count;
+ let sold = hold.take_kept(at, count);
+ hold.earn(earned);
+ Some((sold.name(), earned))
+}
+
+/// Sells all the salvage you have, on you and aboard. Returns how many
+/// pieces and what they fetched, or `None` if there was none.
+pub fn sell_all_salvage(hold: &mut Hold, system: &System, place: usize) -> Option<(u32, u32)> {
+ let (mut pieces, mut earned) = (0, 0);
+ // From the back, so selling one doesn't move the others.
+ let kept: Vec<Kept> = salvage_kept(hold, system, place).into_iter().map(|(at, _)| at).collect();
+ for at in kept.into_iter().rev() {
+ let count = hold.kept_mut(at).map_or(0, |s| s.count);
+ if let Some((_, paid)) = sell_salvage(hold, system, place, at) {
+ pieces += count;
+ earned += paid;
+ }
+ }
+ (pieces > 0).then_some((pieces, earned))
+}
+
+/// The most medpatches a doctor will sell you: they don't hold with
+/// hoarding.
+pub const MEDPATCH_LIMIT: u32 = 9;
+
+pub fn medpatch_price(system: &System, place: usize) -> u32 {
+ local(system, place, "medpatch", 8.0, 16.0).round() as u32
+}
+
+/// What a scholar charges to tell you what an artifact is.
+pub fn identify_fee(system: &System, place: usize) -> u32 {
+ local(system, place, "scholar", 10.0, 25.0).round() as u32
+}
+
+/// What an artifact is worth, once you know what it is. Fixed by its name,
+/// so the same artifact is worth the same anywhere.
+pub fn artifact_value(artifact: &Artifact) -> u32 {
+ let h = seed::derive(0, &artifact.true_name, 0);
+ 40 + (h % 29) as u32 * 5
+}
+
+/// What this place's scholar will pay for an identified artifact.
+pub fn artifact_offer(system: &System, place: usize, artifact: &Artifact) -> u32 {
+ let k = local(system, place, "collector", COLLECTED.0, COLLECTED.1);
+ (f64::from(artifact_value(artifact)) * k).round() as u32
+}
+
+/// What an armourer charges for weapons and pays for them, against what
+/// they're worth (`arms::value`).
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Terms {
+ sells_at: f64,
+ buys_at: f64,
+}
+
+impl Terms {
+ /// What they want for `item`, if it's a weapon or armour.
+ pub fn asking(self, item: &Item) -> Option<u32> {
+ self.price(item, self.sells_at)
+ }
+
+ /// What they'd give you for `item`, if it's a weapon or armour.
+ pub fn offer(self, item: &Item) -> Option<u32> {
+ self.price(item, self.buys_at)
+ }
+
+ fn price(self, item: &Item, k: f64) -> Option<u32> {
+ let worth = match item {
+ Item::Weapon(w) => f64::from(arms::value(w)),
+ Item::Armor(a) => armour::worth(a),
+ _ => return None,
+ };
+ Some((worth * k).round().max(1.0) as u32)
+ }
+
+ /// What they want to mend `a` whole: nothing if it's whole already,
+ /// and for one worn out about half what they'd sell it for.
+ pub fn mending(self, a: &Armor) -> u32 {
+ let k = armour::mending(a) * self.sells_at;
+ if a.durability >= a.max_durability { 0 } else { k.ceil().max(1.0) as u32 }
+ }
+}
+
+/// The armourer's terms at a place: it charges about what a weapon or a
+/// piece of armour's worth, give or take, and pays about half that.
+pub fn armoury_terms(system: &System, place: usize) -> Terms {
+ let sells_at = local(system, place, "arms", ARMS_SOLD.0, ARMS_SOLD.1);
+ let buys_at = sells_at * local(system, place, "arms bought", ARMS_BOUGHT.0, ARMS_BOUGHT.1);
+ Terms { sells_at, buys_at }
+}
+
+/// An armourer's rack, as it stands this fortnight: what it was stocked with,
+/// less what you've bought, and with what you've sold it.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Rack {
+ /// The system's seed, and the place in it.
+ pub system: u64,
+ pub place: usize,
+ /// Which fortnight it's stocked for (`fortnight`). Saves from when
+ /// racks were restocked every week call it `week`.
+ #[serde(alias = "week")]
+ pub fortnight: u64,
+ pub weapons: Vec<Weapon>,
+ /// Racks from before there was armour have none until they're
+ /// restocked.
+ #[serde(default)]
+ pub armour: Vec<Armor>,
+}
+
+/// How many days a rack stays as it is before it's restocked.
+pub const RESTOCK_DAYS: f64 = 14.0;
+
+/// The fortnight `day` is in, as far as racks go.
+pub fn fortnight(day: f64) -> u64 {
+ (day.max(0.0) / RESTOCK_DAYS) as u64
+}
+
+/// What an armourer where `culture` lives stocks in `fortnight`: six to ten
+/// weapons, and about one rack in five has a relic among them.
+pub fn stock(system: &System, place: usize, culture: Culture, fortnight: u64) -> Vec<Weapon> {
+ let rack = seed::derive(system.seed, "rack", place as u64);
+ // Still "week", from when racks were restocked weekly.
+ let mut rng = seed::rng(seed::derive(rack, "week", fortnight));
+ let n = rng.random_range(6..=10);
+ let relic = rng.random_bool(0.2);
+ (0..n).map(|i| arms::stocked(&mut rng, culture, relic && i == 0)).collect()
+}
+
+/// The armour beside the weapons that fortnight: two to five pieces, mostly
+/// of the sort `culture` makes, and now and then a relic.
+pub fn stock_armour(system: &System, place: usize, culture: Culture, fortnight: u64) -> Vec<Armor> {
+ let rack = seed::derive(system.seed, "armour rack", place as u64);
+ let mut rng = seed::rng(seed::derive(rack, "week", fortnight));
+ let n = rng.random_range(2..=5);
+ let relic = rng.random_bool(0.12);
+ (0..n).map(|i| armour::stocked(&mut rng, culture, relic && i == 0)).collect()
+}
+
+/// The rack at `place` this fortnight, stocked afresh if the one you knew
+/// is from another. Racks from other fortnights are forgotten.
+pub fn rack<'a>(
+ racks: &'a mut Vec<Rack>,
+ system: &System,
+ place: usize,
+ culture: Culture,
+ day: f64,
+) -> &'a mut Rack {
+ let fortnight = fortnight(day);
+ racks.retain(|r| r.fortnight == fortnight);
+ let i = match racks.iter().position(|r| r.system == system.seed && r.place == place) {
+ Some(i) => i,
+ None => {
+ let weapons = stock(system, place, culture, fortnight);
+ let armour = stock_armour(system, place, culture, fortnight);
+ racks.push(Rack { system: system.seed, place, fortnight, weapons, armour });
+ racks.len() - 1
+ }
+ };
+ &mut racks[i]
+}
+
+pub const ROUND_PRICE: u32 = 3;
+
+/// Cargo racks: each set adds this many tons of ore space.
+pub const RACK_TONS: u32 = 20;
+const RACK_COSTS: [u32; 3] = [150, 300, 500];
+/// A better lens makes the laser cut this much faster, per lens.
+const LENS_BOOST: f64 = 0.6;
+const LENS_COSTS: [u32; 2] = [200, 450];
+
+/// An extra fuel tank: each adds this many jumps.
+pub const TANK_JUMPS: u32 = 2;
+const TANK_COSTS: [u32; 2] = [350, 700];
+/// The most fuel a ship can hold, with every tank fitted.
+pub const MAX_FUEL: u32 = Hold::BASE_FUEL + TANK_JUMPS * TANK_COSTS.len() as u32;
+
+/// What the next extra tank costs, if there's room for one.
+pub fn tank_cost(hold: &Hold) -> Option<u32> {
+ let fitted = hold.fuel_capacity.saturating_sub(Hold::BASE_FUEL) / TANK_JUMPS;
+ TANK_COSTS.get(fitted as usize).copied()
+}
+
+/// What a jump's worth of fuel costs at a place.
+pub fn fuel_price(system: &System, place: usize) -> u32 {
+ local(system, place, "fuel", 18.0, 34.0).round() as u32
+}
+
+/// Fills the tank with as much fuel as there's room for and you can pay
+/// for. Returns how many jumps' worth you bought, and for how much.
+pub fn buy_fuel(hold: &mut Hold, price: u32) -> (u32, u32) {
+ let room = hold.fuel_capacity.saturating_sub(hold.fuel);
+ let jumps = room.min(hold.scrip / price.max(1));
+ hold.fuel += jumps;
+ hold.scrip -= jumps * price;
+ (jumps, jumps * price)
+}
+
+/// What a tow costs, `jumps` jumps to somewhere that sells fuel.
+pub fn tow_fee(jumps: usize) -> u32 {
+ 150 + 75 * jumps as u32
+}
+
+/// How it went at a counter, when some of what you earned went to your
+/// debt: " 40 ¤ of it went to what you owe." or nothing.
+pub fn debt_note(paid: u32, hold: &Hold) -> String {
+ match (paid, hold.debt) {
+ (0, _) => String::new(),
+ (p, 0) => format!(" {p} ¤ of it paid off your debt."),
+ (p, left) => format!(" {p} ¤ of it went to your debt; you still owe {left} ¤."),
+ }
+}
+
+/// What the next set of racks costs, if there's room for more.
+pub fn rack_cost(hold: &Hold) -> Option<u32> {
+ let fitted = (hold.ore_capacity - Hold::BASE_ORE_CAPACITY) / RACK_TONS;
+ RACK_COSTS.get(fitted as usize).copied()
+}
+
+/// What the next lens costs, if there's a better one to fit.
+pub fn lens_cost(lenses: u32) -> Option<u32> {
+ LENS_COSTS.get(lenses as usize).copied()
+}
+
+/// How fast the laser cuts, 1 being as it came.
+pub fn laser_power(lenses: u32) -> f64 {
+ 1.0 + LENS_BOOST * f64::from(lenses)
+}
+
+/// Things people say in taverns, about the rest of the system: where the
+/// wrecks are, where ore sells best. Shuffled for this visit.
+pub fn rumours(
+ system: &System,
+ place: usize,
+ day: f64,
+ stripped: &HashSet<u64>,
+ visit: u64,
+ beyond: Vec<String>,
+) -> Vec<String> {
+ let mut said = beyond;
+ for w in system.beacons(day, stripped) {
+ let near = &system.places[w.place].name;
+ let left = w.leaves - day;
+ said.push(match day - w.arrived {
+ ..3.0 => format!(
+ "A {} went dark near {near}, just now. Its beacon's crying fresh.",
+ w.description
+ ),
+ _ if left < 5.0 => format!(
+ "There's a {} near {near}, but its beacon's fading. Days left, if that.",
+ w.description
+ ),
+ _ => format!(
+ "A {} went dark near {near}. Its beacon's still crying, they say.",
+ w.description
+ ),
+ });
+ }
+ // Where each of the three dearest ores sells best, other than here.
+ for (i, ore) in ORES.iter().enumerate().rev().take(3) {
+ let best = (0..system.places.len())
+ .filter(|&p| p != place && sells_ore(system, p))
+ .max_by_key(|&p| ore_prices(system, p)[i].1);
+ if let Some(p) = best {
+ let price = ore_prices(system, p)[i].1;
+ said.push(format!(
+ "{} pays {price} ¤ a ton for {}. Best in the system.",
+ capitalise(&system.places[p].name),
+ ore.name
+ ));
+ }
+ }
+ said.push(format!(
+ "The rocks of {} are thick with ore, if you've a laser and patience.",
+ system.belt.name
+ ));
+ for planet in system.planets.iter().filter(|p| p.kind.giant) {
+ said.push(format!(
+ "Nothing lands on {}. Cloud-skimmers dive it for fuel, and most come back.",
+ planet.name
+ ));
+ }
+ for station in &system.stations {
+ said.push(format!("{} buys ore too, and asks fewer questions.", capitalise(&station.name)));
+ }
+ said.shuffle(&mut seed::rng(seed::derive(system.seed, "rumours", visit)));
+ said
+}
+
+/// What people say about the systems a lane joins to this one: which are
+/// dead, which are rich, whose space you'd be in.
+pub fn lane_rumours(galaxy: &Galaxy, star: usize) -> Vec<String> {
+ let here = &galaxy.sites[star];
+ let mut said = Vec::new();
+ for &n in &here.lanes {
+ let there = &galaxy.sites[n];
+ let name = &there.name;
+ let people = there.culture.name();
+ said.push(match there.kind {
+ SystemKind::Desolate => format!(
+ "No one lives in {name} any more. Fill your tank before you jump there, \
+ or you'll be waiting on a tow."
+ ),
+ SystemKind::Graveyard => format!(
+ "{name}'s an old battlefield. Wrecks everywhere, and not all of them empty."
+ ),
+ SystemKind::Rubble => {
+ format!("The belt at {name} is the richest rock this side of the lanes.")
+ }
+ SystemKind::Seat => format!(
+ "{name}'s the seat of {people}. Everything's dearer there, and everyone's watching."
+ ),
+ SystemKind::Frontier => {
+ format!("Out at {name} there's hardly anyone. Fuel, though, if you ask nicely.")
+ }
+ SystemKind::Settled if there.culture != here.culture => {
+ format!("Jump to {name} and you're in the space of {people}. Mind your manners.")
+ }
+ SystemKind::Settled => format!("{name}'s quiet. Good markets, no trouble."),
+ });
+ }
+ said
+}
+
+/// Whether anyone at a place buys ore: wherever people live and you can
+/// land or dock. Not a giant's cloud cities, which you can't reach.
+pub fn sells_ore(system: &System, place: usize) -> bool {
+ system.landing(place).trades()
+}
+
+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 super::*;
+ use crate::names;
+ use crate::space::system;
+
+ #[test]
+ fn an_armourer_sells_dear_and_buys_cheap_and_restocks_fortnightly() {
+ let s = system::generate(3);
+ let terms = armoury_terms(&s, 0);
+ let mut racks = Vec::new();
+ let weapons = rack(&mut racks, &s, 0, Culture::Cant, 2.0).weapons.clone();
+ assert!((6..=10).contains(&weapons.len()));
+ for w in &weapons {
+ let item = Item::Weapon(w.clone());
+ let (asking, offer) = (terms.asking(&item).unwrap(), terms.offer(&item).unwrap());
+ assert!(offer * 3 < asking * 2 && offer * 5 > asking * 2, "{asking} vs {offer}");
+ }
+ assert_eq!(terms.offer(&Item::Medpatch), None, "arms and armour only");
+ // What you buy is gone from the rack until it's restocked.
+ rack(&mut racks, &s, 0, Culture::Cant, 2.0).weapons.remove(0);
+ assert_eq!(rack(&mut racks, &s, 0, Culture::Cant, 13.9).weapons, weapons[1..]);
+ assert_eq!(rack(&mut racks, &s, 0, Culture::Cant, 14.5).weapons.len(), {
+ stock(&s, 0, Culture::Cant, 1).len()
+ });
+ assert_eq!(racks.len(), 1, "last fortnight's rack is forgotten");
+ }
+
+ #[test]
+ fn armour_hangs_beside_the_weapons_and_mending_goes_by_wear() {
+ let s = system::generate(3);
+ let terms = armoury_terms(&s, 0);
+ let mut racks = Vec::new();
+ let armour = rack(&mut racks, &s, 0, Culture::Qaari, 2.0).armour.clone();
+ assert!((2..=5).contains(&armour.len()));
+ let mut a = armour[0].clone();
+ assert_eq!(a.durability, a.max_durability);
+ assert_eq!(terms.mending(&a), 0, "whole already");
+ let item = |a: &Armor| Item::Armor(a.clone());
+ let (whole, offer) = (terms.asking(&item(&a)).unwrap(), terms.offer(&item(&a)).unwrap());
+ assert!(offer < whole);
+ a.durability = 0;
+ let ruined = terms.mending(&a);
+ assert!(ruined.abs_diff(whole / 2) <= 1, "half its price from ruined: {ruined} of {whole}");
+ assert!(terms.offer(&item(&a)).unwrap() < offer, "worth less worn out");
+ a.durability = a.max_durability - 1;
+ assert!((1..ruined).contains(&terms.mending(&a)), "a little wear, a little to mend");
+ // Over the months, now and then a relic.
+ let relics = (0..100)
+ .flat_map(|fortnight| stock_armour(&s, 0, Culture::Houses, fortnight))
+ .filter(|a| a.named)
+ .count();
+ assert!((3..30).contains(&relics), "{relics} relics in 100 racks");
+ }
+
+ #[test]
+ fn a_typical_offer_is_somewhere_in_the_middle_of_what_places_pay() {
+ let s = system::generate(3);
+ let mut rng = seed::rng(1);
+ let w = Item::Weapon(arms::find(&mut rng, arms::Source::Armoury, false));
+ let a = Item::Armor(armour::find(&mut rng, arms::Source::Armoury, false));
+ for item in [w, a] {
+ let typical = typical_offer(&item).unwrap();
+ let offers: Vec<u32> =
+ (0..40).map(|place| armoury_terms(&s, place).offer(&item).unwrap()).collect();
+ let (lo, hi) = (*offers.iter().min().unwrap(), *offers.iter().max().unwrap());
+ assert!((lo..=hi).contains(&typical), "{typical} not in {lo}..={hi}");
+ }
+ let scrap = Salvage { title: "scrap".into(), value: 40, mass: 1000 };
+ assert_eq!(typical_offer(&Item::Salvage(scrap)), Some(39));
+ assert_eq!(typical_offer(&Item::Medpatch), None, "no one buys medpatches");
+ let mut found = names::artifact(&mut rng);
+ assert_eq!(typical_offer(&Item::Artifact(found.clone())), None, "not until it's named");
+ found.identified = true;
+ assert!(typical_offer(&Item::Artifact(found)).is_some());
+ }
+
+ #[test]
+ fn racks_lean_to_their_peoples_weapons_and_now_and_then_a_relic() {
+ let s = system::generate(5);
+ let racks: Vec<Vec<Weapon>> =
+ (0..60).map(|fortnight| stock(&s, 1, Culture::Drift, fortnight)).collect();
+ let all: Vec<&Weapon> = racks.iter().flatten().collect();
+ let guns = all.iter().filter(|w| w.is_gun()).count();
+ assert!(guns * 3 > all.len() * 2, "the Drift sell mostly guns: {guns} of {}", all.len());
+ let relics = racks.iter().filter(|r| r.iter().any(|w| w.named)).count();
+ assert!((4..=24).contains(&relics), "{relics} racks of 60 with a relic");
+ let worth = |w: &Weapon| arms::value(w);
+ let relic = all.iter().filter(|w| w.named).map(|w| worth(w)).min().unwrap();
+ let plain = all.iter().filter(|w| !w.named).map(|w| worth(w)).max().unwrap();
+ assert!(relic * 3 > plain, "relics are dear: {relic} against {plain}");
+ }
+
+ #[test]
+ fn selling_ore_pays_the_local_price() {
+ let s = system::generate(1);
+ let prices = ore_prices(&s, 0);
+ let mut hold = Hold::default();
+ assert_eq!(sell_ore(&mut hold, &prices), None);
+ hold.ore.insert("iron ore", 3);
+ hold.ore.insert("iridium", 2);
+ let price = |name: &str| prices.iter().find(|(o, _)| o.name == name).unwrap().1;
+ let expected = 3 * price("iron ore") + 2 * price("iridium");
+ assert_eq!(sell_ore(&mut hold, &prices), Some((5, expected)));
+ assert_eq!((hold.scrip, hold.ore_tons()), (expected, 0));
+ }
+
+ #[test]
+ fn prices_differ_between_places_but_not_between_visits() {
+ let s = system::generate(1);
+ let all: Vec<_> = (0..s.places.len()).map(|p| ore_prices(&s, p)[0].1).collect();
+ assert!(all.iter().any(|&p| p != all[0]));
+ assert_eq!(ore_prices(&s, 1), ore_prices(&s, 1));
+ assert_eq!(medpatch_price(&s, 2), medpatch_price(&s, 2));
+ }
+
+ #[test]
+ fn artifacts_are_worth_the_same_everywhere_give_or_take() {
+ let s = system::generate(1);
+ let a = names::artifact(&mut seed::rng(4));
+ let v = artifact_value(&a);
+ assert!((40..=180).contains(&v));
+ for place in 0..s.places.len() {
+ let offer = artifact_offer(&s, place, &a);
+ assert!(offer >= v * 4 / 5 && offer <= v * 5 / 4 + 1);
+ }
+ }
+
+ #[test]
+ fn fittings_run_out() {
+ let mut hold = Hold::default();
+ let mut spent = 0;
+ while let Some(cost) = rack_cost(&hold) {
+ spent += cost;
+ hold.ore_capacity += RACK_TONS;
+ }
+ assert_eq!(hold.ore_capacity, Hold::BASE_ORE_CAPACITY + 3 * RACK_TONS);
+ assert_eq!(spent, 950);
+ assert_eq!(lens_cost(2), None);
+ assert!(laser_power(2) > laser_power(0));
+ while tank_cost(&hold).is_some() {
+ hold.fuel_capacity += TANK_JUMPS;
+ }
+ assert_eq!(hold.fuel_capacity, MAX_FUEL);
+ }
+
+ #[test]
+ fn fuel_fills_as_far_as_money_goes() {
+ let mut hold = Hold { fuel: 1, scrip: 50, ..Hold::default() };
+ assert_eq!(buy_fuel(&mut hold, 20), (2, 40));
+ assert_eq!((hold.fuel, hold.scrip), (3, 10));
+ hold.scrip = 500;
+ assert_eq!(buy_fuel(&mut hold, 20), (1, 20), "only as much as fits");
+ assert_eq!(buy_fuel(&mut hold, 20), (0, 0));
+ }
+
+ #[test]
+ fn debts_are_paid_from_what_you_earn() {
+ let mut hold = Hold { scrip: 100, ..Hold::default() };
+ assert_eq!(hold.charge(250), 150);
+ assert_eq!((hold.scrip, hold.debt), (0, 150));
+ assert_eq!(hold.earn(100), 100);
+ assert_eq!((hold.scrip, hold.debt), (0, 50));
+ assert_eq!(hold.earn(80), 50);
+ assert_eq!((hold.scrip, hold.debt), (30, 0));
+ assert!(debt_note(50, &hold).contains("paid off"));
+ }
+
+ #[test]
+ fn taverns_talk_about_wrecks_until_theyre_stripped() {
+ let s = system::generate(1);
+ let day = (0..200).map(f64::from).find(|&d| !s.wrecks_on(d).is_empty()).unwrap();
+ let calling = s.wrecks_on(day);
+ let wreck = &calling[0];
+ let mentions = |talk: &[String]| {
+ let about = |w: &system::Wreck| {
+ let near = format!("near {}", s.places[w.place].name);
+ talk.iter().filter(|r| r.contains(&w.description) && r.contains(&near)).count()
+ };
+ calling.iter().map(about).sum::<usize>()
+ };
+ let talk = rumours(&s, 0, day, &HashSet::new(), 0, Vec::new());
+ assert!(talk.iter().any(|r| r.contains(&wreck.description)));
+ let everyone = mentions(&talk);
+ let talk = rumours(&s, 0, day, &HashSet::from([wreck.seed]), 0, Vec::new());
+ assert!(mentions(&talk) < everyone, "no one talks of a wreck you've stripped");
+ }
+}