From 9ccf951473e21a8b90c25161156fa09a9b227a7b Mon Sep 17 00:00:00 2001 From: skylar Date: Wed, 11 Nov 2026 15:24:37 -0800 Subject: Orrery 0.1.0: the galaxy, flight, cities, inventory and saves Grows the derelict crawler into the whole game, and gets it ready to publish. - A galaxy of star systems joined by lanes, split between four peoples' territories, with a star map and a system map. Systems are modelled with Kepler orbits, moons inside their Hill spheres, belts past the frost line and stations at Lagrange points. - Flight around one place at a time: arcade flight with a throttle fallback where held keys can't be detected, mining rocks for ore, landing, docking at stations, and boarding wrecks that come and go by the calendar. - Your ship to walk about, and capital cities to land at: generated plans with walls, quarters and streets, building interiors, shops, taverns, scholars and armourers. - Fighting with a weapon and a gun ready, six damage types and how each monster takes them, and armour that guards, wears and can be mended. - Everything you own has a weight: a vim-keyed inventory, a pack and the ship's lockers, and trade in ore, salvage, arms and fuel. - Characters with a people and a ship of their own, saved as versioned JSON on every scene change and on quit, with a character sheet, a saves list and a pause menu wherever you are. - `orrery weapons`, `armour`, `galaxy`, `system` and `town` print the generators' output without the TUI. - Release: README install steps for macOS and Linux, a Makefile whose install also works with macOS's install, an MIT licence, and Cargo metadata with rust-version 1.88. Co-Authored-By: Claude Opus 5.5 --- src/scenes/town.rs | 2509 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2509 insertions(+) create mode 100644 src/scenes/town.rs (limited to 'src/scenes/town.rs') 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, +} + +pub struct TownScene { + town: Town, + /// The building you're in, if you're indoors. + inside: Option, + 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, + /// How many things have been said, ever: the log itself stops growing. + said: usize, + trading: Option, + /// Looking at the whole city. + map: bool, + /// Buildings you've been into. + visited: HashSet, + rumours: Vec, + heard: usize, + rng: Rng, + /// Open over the city when you're looking through what you carry. + inventory: Option, + /// The armourer whose rack is beside you in the inventory, when you're + /// trading with one. + shopping: Option, + /// What you've put down, and where: in the streets, or in a building. + dropped: Vec<(Spot, Stack)>, + /// The menu `esc` opens. + pause: Option, +} + +/// Somewhere in the city: the building it's in, if it's indoors, and where +/// in it. +type Spot = (Option, 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 { + 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, + aboard: Option, + ctx: &mut Ctx, + ) -> String { + let terms = trade::armoury_terms(&ctx.system, self.place); + let h = &mut ctx.hold; + let dealt = |s: Option| 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, + aboard: Option, + 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 { + let t = &self.town; + let unseen: Vec = 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 = 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 { + 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 = 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 = 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 = 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 { + 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 { + let mut rows: Vec = 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)); + } +} -- cgit v1.2.3