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