//! What things cost and what they fetch: ore and salvage at markets, //! medpatches, scholars' fees and offers, weapons and armour at an //! armourer's (and mending it), ship //! fittings, bigger packs, fuel and tows, and the talk you can buy in a //! tavern. Prices vary from place to place but stay the same at any one //! place, visit after visit. use std::collections::HashSet; use rand::RngExt; use rand::seq::SliceRandom; use serde::{Deserialize, Serialize}; use crate::app::{Hold, Kept}; use crate::dungeon::armour::{self, Armor}; use crate::dungeon::arms::{self, Weapon}; use crate::items::{Item, Salvage}; use crate::names::{Artifact, Culture}; use crate::scenes::sky::hash01; use crate::seed; use crate::space::galaxy::Galaxy; use crate::space::locale::{ORES, Ore}; use crate::space::system::{System, SystemKind}; /// A number from `low` to `high`, fixed for this place and `what`. fn local(system: &System, place: usize, what: &str, low: f64, high: f64) -> f64 { let k = hash01(seed::derive(system.seed, what, place as u64), 0, 0); low + (high - low) * k } /// What each ore fetches at a place: its value, give or take. pub fn ore_prices(system: &System, place: usize) -> Vec<(&'static Ore, u32)> { let market = seed::derive(system.seed, "market", place as u64); ORES.iter() .enumerate() .map(|(i, ore)| { let k = 0.7 + 0.7 * hash01(market, i as i64, 0); (ore, (f64::from(ore.value) * k).round().max(1.0) as u32) }) .collect() } /// Sells all the ore in the hold at these prices. Returns the tons sold and /// what they fetched, or `None` if there was nothing to sell. pub fn sell_ore(hold: &mut Hold, prices: &[(&'static Ore, u32)]) -> Option<(u32, u32)> { let tons = hold.ore_tons(); if tons == 0 { return None; } let price = |name: &str| prices.iter().find(|(o, _)| o.name == name).map_or(0, |(_, p)| *p); let earned = hold.ore.iter().map(|(name, t)| t * price(name)).sum(); hold.ore.clear(); hold.earn(earned); Some((tons, earned)) } /// What brokers pay for salvage, against what it's worth: from this to this, /// depending on the place. const SCRAP: (f64, f64) = (0.75, 1.2); /// What armourers charge for arms and armour, against what they're worth. const ARMS_SOLD: (f64, f64) = (0.9, 1.2); /// What armourers pay, against what they charge. const ARMS_BOUGHT: (f64, f64) = (0.45, 0.55); /// What scholars pay for an artifact, against what it's worth. const COLLECTED: (f64, f64) = (0.8, 1.25); fn middle((low, high): (f64, f64)) -> f64 { (low + high) / 2.0 } /// About what one of `item` would fetch from whoever buys that sort of /// thing, at a place in the middle of the range: an armourer for arms and /// armour, a broker for salvage, a scholar for an artifact once one's told /// you what it is. `None` for what no one buys, and for an artifact no one /// has named yet. pub fn typical_offer(item: &Item) -> Option { let arms = middle(ARMS_SOLD) * middle(ARMS_BOUGHT); let worth = match item { Item::Weapon(w) => f64::from(arms::value(w)) * arms, Item::Armor(a) => armour::worth(a) * arms, Item::Salvage(s) => f64::from(s.value) * middle(SCRAP), Item::Artifact(a) if a.identified => f64::from(artifact_value(a)) * middle(COLLECTED), _ => return None, }; Some(worth.round().max(1.0) as u32) } /// What a broker here pays for one piece of salvage: what it's worth, give /// or take. pub fn salvage_offer(system: &System, place: usize, salvage: &Salvage) -> u32 { let k = local(system, place, "scrap", SCRAP.0, SCRAP.1); (f64::from(salvage.value) * k).round().max(1.0) as u32 } /// Every stack of salvage you have, on you and aboard, with what all of it /// would fetch here. pub fn salvage_kept(hold: &Hold, system: &System, place: usize) -> Vec<(Kept, u32)> { hold.kept() .filter_map(|(at, s)| match &s.item { Item::Salvage(v) => Some((at, salvage_offer(system, place, v) * s.count)), _ => None, }) .collect() } /// Sells the salvage kept `at` for what a broker here pays. Returns what it /// was and what it fetched, or `None` if there's no salvage there. pub fn sell_salvage( hold: &mut Hold, system: &System, place: usize, at: Kept, ) -> Option<(String, u32)> { let stack = hold.kept_mut(at)?; let Item::Salvage(v) = &stack.item else { return None }; let earned = salvage_offer(system, place, v) * stack.count; let count = stack.count; let sold = hold.take_kept(at, count); hold.earn(earned); Some((sold.name(), earned)) } /// Sells all the salvage you have, on you and aboard. Returns how many /// pieces and what they fetched, or `None` if there was none. pub fn sell_all_salvage(hold: &mut Hold, system: &System, place: usize) -> Option<(u32, u32)> { let (mut pieces, mut earned) = (0, 0); // From the back, so selling one doesn't move the others. let kept: Vec = salvage_kept(hold, system, place).into_iter().map(|(at, _)| at).collect(); for at in kept.into_iter().rev() { let count = hold.kept_mut(at).map_or(0, |s| s.count); if let Some((_, paid)) = sell_salvage(hold, system, place, at) { pieces += count; earned += paid; } } (pieces > 0).then_some((pieces, earned)) } /// The most medpatches a doctor will sell you: they don't hold with /// hoarding. pub const MEDPATCH_LIMIT: u32 = 9; pub fn medpatch_price(system: &System, place: usize) -> u32 { local(system, place, "medpatch", 8.0, 16.0).round() as u32 } /// What a scholar charges to tell you what an artifact is. pub fn identify_fee(system: &System, place: usize) -> u32 { local(system, place, "scholar", 10.0, 25.0).round() as u32 } /// What an artifact is worth, once you know what it is. Fixed by its name, /// so the same artifact is worth the same anywhere. pub fn artifact_value(artifact: &Artifact) -> u32 { let h = seed::derive(0, &artifact.true_name, 0); 40 + (h % 29) as u32 * 5 } /// What this place's scholar will pay for an identified artifact. pub fn artifact_offer(system: &System, place: usize, artifact: &Artifact) -> u32 { let k = local(system, place, "collector", COLLECTED.0, COLLECTED.1); (f64::from(artifact_value(artifact)) * k).round() as u32 } /// What an armourer charges for weapons and pays for them, against what /// they're worth (`arms::value`). #[derive(Clone, Copy, Debug, PartialEq)] pub struct Terms { sells_at: f64, buys_at: f64, } impl Terms { /// What they want for `item`, if it's a weapon or armour. pub fn asking(self, item: &Item) -> Option { self.price(item, self.sells_at) } /// What they'd give you for `item`, if it's a weapon or armour. pub fn offer(self, item: &Item) -> Option { self.price(item, self.buys_at) } fn price(self, item: &Item, k: f64) -> Option { let worth = match item { Item::Weapon(w) => f64::from(arms::value(w)), Item::Armor(a) => armour::worth(a), _ => return None, }; Some((worth * k).round().max(1.0) as u32) } /// What they want to mend `a` whole: nothing if it's whole already, /// and for one worn out about half what they'd sell it for. pub fn mending(self, a: &Armor) -> u32 { let k = armour::mending(a) * self.sells_at; if a.durability >= a.max_durability { 0 } else { k.ceil().max(1.0) as u32 } } } /// The armourer's terms at a place: it charges about what a weapon or a /// piece of armour's worth, give or take, and pays about half that. pub fn armoury_terms(system: &System, place: usize) -> Terms { let sells_at = local(system, place, "arms", ARMS_SOLD.0, ARMS_SOLD.1); let buys_at = sells_at * local(system, place, "arms bought", ARMS_BOUGHT.0, ARMS_BOUGHT.1); Terms { sells_at, buys_at } } /// An armourer's rack, as it stands this fortnight: what it was stocked with, /// less what you've bought, and with what you've sold it. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Rack { /// The system's seed, and the place in it. pub system: u64, pub place: usize, /// Which fortnight it's stocked for (`fortnight`). Saves from when /// racks were restocked every week call it `week`. #[serde(alias = "week")] pub fortnight: u64, pub weapons: Vec, /// Racks from before there was armour have none until they're /// restocked. #[serde(default)] pub armour: Vec, } /// How many days a rack stays as it is before it's restocked. pub const RESTOCK_DAYS: f64 = 14.0; /// The fortnight `day` is in, as far as racks go. pub fn fortnight(day: f64) -> u64 { (day.max(0.0) / RESTOCK_DAYS) as u64 } /// What an armourer where `culture` lives stocks in `fortnight`: six to ten /// weapons, and about one rack in five has a relic among them. pub fn stock(system: &System, place: usize, culture: Culture, fortnight: u64) -> Vec { let rack = seed::derive(system.seed, "rack", place as u64); // Still "week", from when racks were restocked weekly. let mut rng = seed::rng(seed::derive(rack, "week", fortnight)); let n = rng.random_range(6..=10); let relic = rng.random_bool(0.2); (0..n).map(|i| arms::stocked(&mut rng, culture, relic && i == 0)).collect() } /// The armour beside the weapons that fortnight: two to five pieces, mostly /// of the sort `culture` makes, and now and then a relic. pub fn stock_armour(system: &System, place: usize, culture: Culture, fortnight: u64) -> Vec { let rack = seed::derive(system.seed, "armour rack", place as u64); let mut rng = seed::rng(seed::derive(rack, "week", fortnight)); let n = rng.random_range(2..=5); let relic = rng.random_bool(0.12); (0..n).map(|i| armour::stocked(&mut rng, culture, relic && i == 0)).collect() } /// The rack at `place` this fortnight, stocked afresh if the one you knew /// is from another. Racks from other fortnights are forgotten. pub fn rack<'a>( racks: &'a mut Vec, system: &System, place: usize, culture: Culture, day: f64, ) -> &'a mut Rack { let fortnight = fortnight(day); racks.retain(|r| r.fortnight == fortnight); let i = match racks.iter().position(|r| r.system == system.seed && r.place == place) { Some(i) => i, None => { let weapons = stock(system, place, culture, fortnight); let armour = stock_armour(system, place, culture, fortnight); racks.push(Rack { system: system.seed, place, fortnight, weapons, armour }); racks.len() - 1 } }; &mut racks[i] } pub const ROUND_PRICE: u32 = 3; /// Cargo racks: each set adds this many tons of ore space. pub const RACK_TONS: u32 = 20; const RACK_COSTS: [u32; 3] = [150, 300, 500]; /// A better lens makes the laser cut this much faster, per lens. const LENS_BOOST: f64 = 0.6; const LENS_COSTS: [u32; 2] = [200, 450]; /// An extra fuel tank: each adds this many jumps. pub const TANK_JUMPS: u32 = 2; const TANK_COSTS: [u32; 2] = [350, 700]; /// The most fuel a ship can hold, with every tank fitted. pub const MAX_FUEL: u32 = Hold::BASE_FUEL + TANK_JUMPS * TANK_COSTS.len() as u32; /// What the next extra tank costs, if there's room for one. pub fn tank_cost(hold: &Hold) -> Option { let fitted = hold.fuel_capacity.saturating_sub(Hold::BASE_FUEL) / TANK_JUMPS; TANK_COSTS.get(fitted as usize).copied() } /// What a jump's worth of fuel costs at a place. pub fn fuel_price(system: &System, place: usize) -> u32 { local(system, place, "fuel", 18.0, 34.0).round() as u32 } /// Fills the tank with as much fuel as there's room for and you can pay /// for. Returns how many jumps' worth you bought, and for how much. pub fn buy_fuel(hold: &mut Hold, price: u32) -> (u32, u32) { let room = hold.fuel_capacity.saturating_sub(hold.fuel); let jumps = room.min(hold.scrip / price.max(1)); hold.fuel += jumps; hold.scrip -= jumps * price; (jumps, jumps * price) } /// What a tow costs, `jumps` jumps to somewhere that sells fuel. pub fn tow_fee(jumps: usize) -> u32 { 150 + 75 * jumps as u32 } /// How it went at a counter, when some of what you earned went to your /// debt: " 40 ¤ of it went to what you owe." or nothing. pub fn debt_note(paid: u32, hold: &Hold) -> String { match (paid, hold.debt) { (0, _) => String::new(), (p, 0) => format!(" {p} ¤ of it paid off your debt."), (p, left) => format!(" {p} ¤ of it went to your debt; you still owe {left} ¤."), } } /// What the next set of racks costs, if there's room for more. pub fn rack_cost(hold: &Hold) -> Option { let fitted = (hold.ore_capacity - Hold::BASE_ORE_CAPACITY) / RACK_TONS; RACK_COSTS.get(fitted as usize).copied() } /// What the next lens costs, if there's a better one to fit. pub fn lens_cost(lenses: u32) -> Option { LENS_COSTS.get(lenses as usize).copied() } /// How fast the laser cuts, 1 being as it came. pub fn laser_power(lenses: u32) -> f64 { 1.0 + LENS_BOOST * f64::from(lenses) } /// Things people say in taverns, about the rest of the system: where the /// wrecks are, where ore sells best. Shuffled for this visit. pub fn rumours( system: &System, place: usize, day: f64, stripped: &HashSet, visit: u64, beyond: Vec, ) -> Vec { let mut said = beyond; for w in system.beacons(day, stripped) { let near = &system.places[w.place].name; let left = w.leaves - day; said.push(match day - w.arrived { ..3.0 => format!( "A {} went dark near {near}, just now. Its beacon's crying fresh.", w.description ), _ if left < 5.0 => format!( "There's a {} near {near}, but its beacon's fading. Days left, if that.", w.description ), _ => format!( "A {} went dark near {near}. Its beacon's still crying, they say.", w.description ), }); } // Where each of the three dearest ores sells best, other than here. for (i, ore) in ORES.iter().enumerate().rev().take(3) { let best = (0..system.places.len()) .filter(|&p| p != place && sells_ore(system, p)) .max_by_key(|&p| ore_prices(system, p)[i].1); if let Some(p) = best { let price = ore_prices(system, p)[i].1; said.push(format!( "{} pays {price} ¤ a ton for {}. Best in the system.", capitalise(&system.places[p].name), ore.name )); } } said.push(format!( "The rocks of {} are thick with ore, if you've a laser and patience.", system.belt.name )); for planet in system.planets.iter().filter(|p| p.kind.giant) { said.push(format!( "Nothing lands on {}. Cloud-skimmers dive it for fuel, and most come back.", planet.name )); } for station in &system.stations { said.push(format!("{} buys ore too, and asks fewer questions.", capitalise(&station.name))); } said.shuffle(&mut seed::rng(seed::derive(system.seed, "rumours", visit))); said } /// What people say about the systems a lane joins to this one: which are /// dead, which are rich, whose space you'd be in. pub fn lane_rumours(galaxy: &Galaxy, star: usize) -> Vec { let here = &galaxy.sites[star]; let mut said = Vec::new(); for &n in &here.lanes { let there = &galaxy.sites[n]; let name = &there.name; let people = there.culture.name(); said.push(match there.kind { SystemKind::Desolate => format!( "No one lives in {name} any more. Fill your tank before you jump there, \ or you'll be waiting on a tow." ), SystemKind::Graveyard => format!( "{name}'s an old battlefield. Wrecks everywhere, and not all of them empty." ), SystemKind::Rubble => { format!("The belt at {name} is the richest rock this side of the lanes.") } SystemKind::Seat => format!( "{name}'s the seat of {people}. Everything's dearer there, and everyone's watching." ), SystemKind::Frontier => { format!("Out at {name} there's hardly anyone. Fuel, though, if you ask nicely.") } SystemKind::Settled if there.culture != here.culture => { format!("Jump to {name} and you're in the space of {people}. Mind your manners.") } SystemKind::Settled => format!("{name}'s quiet. Good markets, no trouble."), }); } said } /// Whether anyone at a place buys ore: wherever people live and you can /// land or dock. Not a giant's cloud cities, which you can't reach. pub fn sells_ore(system: &System, place: usize) -> bool { system.landing(place).trades() } fn capitalise(s: &str) -> String { let mut chars = s.chars(); chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) } #[cfg(test)] mod tests { use super::*; use crate::names; use crate::space::system; #[test] fn an_armourer_sells_dear_and_buys_cheap_and_restocks_fortnightly() { let s = system::generate(3); let terms = armoury_terms(&s, 0); let mut racks = Vec::new(); let weapons = rack(&mut racks, &s, 0, Culture::Cant, 2.0).weapons.clone(); assert!((6..=10).contains(&weapons.len())); for w in &weapons { let item = Item::Weapon(w.clone()); let (asking, offer) = (terms.asking(&item).unwrap(), terms.offer(&item).unwrap()); assert!(offer * 3 < asking * 2 && offer * 5 > asking * 2, "{asking} vs {offer}"); } assert_eq!(terms.offer(&Item::Medpatch), None, "arms and armour only"); // What you buy is gone from the rack until it's restocked. rack(&mut racks, &s, 0, Culture::Cant, 2.0).weapons.remove(0); assert_eq!(rack(&mut racks, &s, 0, Culture::Cant, 13.9).weapons, weapons[1..]); assert_eq!(rack(&mut racks, &s, 0, Culture::Cant, 14.5).weapons.len(), { stock(&s, 0, Culture::Cant, 1).len() }); assert_eq!(racks.len(), 1, "last fortnight's rack is forgotten"); } #[test] fn armour_hangs_beside_the_weapons_and_mending_goes_by_wear() { let s = system::generate(3); let terms = armoury_terms(&s, 0); let mut racks = Vec::new(); let armour = rack(&mut racks, &s, 0, Culture::Qaari, 2.0).armour.clone(); assert!((2..=5).contains(&armour.len())); let mut a = armour[0].clone(); assert_eq!(a.durability, a.max_durability); assert_eq!(terms.mending(&a), 0, "whole already"); let item = |a: &Armor| Item::Armor(a.clone()); let (whole, offer) = (terms.asking(&item(&a)).unwrap(), terms.offer(&item(&a)).unwrap()); assert!(offer < whole); a.durability = 0; let ruined = terms.mending(&a); assert!(ruined.abs_diff(whole / 2) <= 1, "half its price from ruined: {ruined} of {whole}"); assert!(terms.offer(&item(&a)).unwrap() < offer, "worth less worn out"); a.durability = a.max_durability - 1; assert!((1..ruined).contains(&terms.mending(&a)), "a little wear, a little to mend"); // Over the months, now and then a relic. let relics = (0..100) .flat_map(|fortnight| stock_armour(&s, 0, Culture::Houses, fortnight)) .filter(|a| a.named) .count(); assert!((3..30).contains(&relics), "{relics} relics in 100 racks"); } #[test] fn a_typical_offer_is_somewhere_in_the_middle_of_what_places_pay() { let s = system::generate(3); let mut rng = seed::rng(1); let w = Item::Weapon(arms::find(&mut rng, arms::Source::Armoury, false)); let a = Item::Armor(armour::find(&mut rng, arms::Source::Armoury, false)); for item in [w, a] { let typical = typical_offer(&item).unwrap(); let offers: Vec = (0..40).map(|place| armoury_terms(&s, place).offer(&item).unwrap()).collect(); let (lo, hi) = (*offers.iter().min().unwrap(), *offers.iter().max().unwrap()); assert!((lo..=hi).contains(&typical), "{typical} not in {lo}..={hi}"); } let scrap = Salvage { title: "scrap".into(), value: 40, mass: 1000 }; assert_eq!(typical_offer(&Item::Salvage(scrap)), Some(39)); assert_eq!(typical_offer(&Item::Medpatch), None, "no one buys medpatches"); let mut found = names::artifact(&mut rng); assert_eq!(typical_offer(&Item::Artifact(found.clone())), None, "not until it's named"); found.identified = true; assert!(typical_offer(&Item::Artifact(found)).is_some()); } #[test] fn racks_lean_to_their_peoples_weapons_and_now_and_then_a_relic() { let s = system::generate(5); let racks: Vec> = (0..60).map(|fortnight| stock(&s, 1, Culture::Drift, fortnight)).collect(); let all: Vec<&Weapon> = racks.iter().flatten().collect(); let guns = all.iter().filter(|w| w.is_gun()).count(); assert!(guns * 3 > all.len() * 2, "the Drift sell mostly guns: {guns} of {}", all.len()); let relics = racks.iter().filter(|r| r.iter().any(|w| w.named)).count(); assert!((4..=24).contains(&relics), "{relics} racks of 60 with a relic"); let worth = |w: &Weapon| arms::value(w); let relic = all.iter().filter(|w| w.named).map(|w| worth(w)).min().unwrap(); let plain = all.iter().filter(|w| !w.named).map(|w| worth(w)).max().unwrap(); assert!(relic * 3 > plain, "relics are dear: {relic} against {plain}"); } #[test] fn selling_ore_pays_the_local_price() { let s = system::generate(1); let prices = ore_prices(&s, 0); let mut hold = Hold::default(); assert_eq!(sell_ore(&mut hold, &prices), None); hold.ore.insert("iron ore", 3); hold.ore.insert("iridium", 2); let price = |name: &str| prices.iter().find(|(o, _)| o.name == name).unwrap().1; let expected = 3 * price("iron ore") + 2 * price("iridium"); assert_eq!(sell_ore(&mut hold, &prices), Some((5, expected))); assert_eq!((hold.scrip, hold.ore_tons()), (expected, 0)); } #[test] fn prices_differ_between_places_but_not_between_visits() { let s = system::generate(1); let all: Vec<_> = (0..s.places.len()).map(|p| ore_prices(&s, p)[0].1).collect(); assert!(all.iter().any(|&p| p != all[0])); assert_eq!(ore_prices(&s, 1), ore_prices(&s, 1)); assert_eq!(medpatch_price(&s, 2), medpatch_price(&s, 2)); } #[test] fn artifacts_are_worth_the_same_everywhere_give_or_take() { let s = system::generate(1); let a = names::artifact(&mut seed::rng(4)); let v = artifact_value(&a); assert!((40..=180).contains(&v)); for place in 0..s.places.len() { let offer = artifact_offer(&s, place, &a); assert!(offer >= v * 4 / 5 && offer <= v * 5 / 4 + 1); } } #[test] fn fittings_run_out() { let mut hold = Hold::default(); let mut spent = 0; while let Some(cost) = rack_cost(&hold) { spent += cost; hold.ore_capacity += RACK_TONS; } assert_eq!(hold.ore_capacity, Hold::BASE_ORE_CAPACITY + 3 * RACK_TONS); assert_eq!(spent, 950); assert_eq!(lens_cost(2), None); assert!(laser_power(2) > laser_power(0)); while tank_cost(&hold).is_some() { hold.fuel_capacity += TANK_JUMPS; } assert_eq!(hold.fuel_capacity, MAX_FUEL); } #[test] fn fuel_fills_as_far_as_money_goes() { let mut hold = Hold { fuel: 1, scrip: 50, ..Hold::default() }; assert_eq!(buy_fuel(&mut hold, 20), (2, 40)); assert_eq!((hold.fuel, hold.scrip), (3, 10)); hold.scrip = 500; assert_eq!(buy_fuel(&mut hold, 20), (1, 20), "only as much as fits"); assert_eq!(buy_fuel(&mut hold, 20), (0, 0)); } #[test] fn debts_are_paid_from_what_you_earn() { let mut hold = Hold { scrip: 100, ..Hold::default() }; assert_eq!(hold.charge(250), 150); assert_eq!((hold.scrip, hold.debt), (0, 150)); assert_eq!(hold.earn(100), 100); assert_eq!((hold.scrip, hold.debt), (0, 50)); assert_eq!(hold.earn(80), 50); assert_eq!((hold.scrip, hold.debt), (30, 0)); assert!(debt_note(50, &hold).contains("paid off")); } #[test] fn taverns_talk_about_wrecks_until_theyre_stripped() { let s = system::generate(1); let day = (0..200).map(f64::from).find(|&d| !s.wrecks_on(d).is_empty()).unwrap(); let calling = s.wrecks_on(day); let wreck = &calling[0]; let mentions = |talk: &[String]| { let about = |w: &system::Wreck| { let near = format!("near {}", s.places[w.place].name); talk.iter().filter(|r| r.contains(&w.description) && r.contains(&near)).count() }; calling.iter().map(about).sum::() }; let talk = rumours(&s, 0, day, &HashSet::new(), 0, Vec::new()); assert!(talk.iter().any(|r| r.contains(&wreck.description))); let everyone = mentions(&talk); let talk = rumours(&s, 0, day, &HashSet::from([wreck.seed]), 0, Vec::new()); assert!(mentions(&talk) < everyone, "no one talks of a wreck you've stripped"); } }