From 9ccf951473e21a8b90c25161156fa09a9b227a7b Mon Sep 17 00:00:00 2001 From: skylar Date: Wed, 11 Nov 2026 15:24:37 -0800 Subject: Orrery 0.1.0: the galaxy, flight, cities, inventory and saves Grows the derelict crawler into the whole game, and gets it ready to publish. - A galaxy of star systems joined by lanes, split between four peoples' territories, with a star map and a system map. Systems are modelled with Kepler orbits, moons inside their Hill spheres, belts past the frost line and stations at Lagrange points. - Flight around one place at a time: arcade flight with a throttle fallback where held keys can't be detected, mining rocks for ore, landing, docking at stations, and boarding wrecks that come and go by the calendar. - Your ship to walk about, and capital cities to land at: generated plans with walls, quarters and streets, building interiors, shops, taverns, scholars and armourers. - Fighting with a weapon and a gun ready, six damage types and how each monster takes them, and armour that guards, wears and can be mended. - Everything you own has a weight: a vim-keyed inventory, a pack and the ship's lockers, and trade in ore, salvage, arms and fuel. - Characters with a people and a ship of their own, saved as versioned JSON on every scene change and on quit, with a character sheet, a saves list and a pause menu wherever you are. - `orrery weapons`, `armour`, `galaxy`, `system` and `town` print the generators' output without the TUI. - Release: README install steps for macOS and Linux, a Makefile whose install also works with macOS's install, an MIT licence, and Cargo metadata with rust-version 1.88. Co-Authored-By: Claude Opus 5.5 --- src/trade.rs | 639 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 639 insertions(+) create mode 100644 src/trade.rs (limited to 'src/trade.rs') 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 { + 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"); + } +} -- cgit v1.2.3