summaryrefslogtreecommitdiff
path: root/src/scenes/town.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/scenes/town.rs')
-rw-r--r--src/scenes/town.rs2509
1 files changed, 2509 insertions, 0 deletions
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));
+ }
+}