//! 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)); } }