diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eeef2699a..e3aa65f467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved site placement - [Server] Kick clients who send messages on the wrong stream +- Reworked Merchant trade price calculation, Merchants offer more wares ### Removed diff --git a/assets/common/trading/item_price_calculation.ron b/assets/common/trading/item_price_calculation.ron index 91d4115094..9fabc61ec4 100644 --- a/assets/common/trading/item_price_calculation.ron +++ b/assets/common/trading/item_price_calculation.ron @@ -47,10 +47,9 @@ loot_tables: [ // this is the amount of that good the most common item represents // so basically this table balances the goods against each other (higher=less valuable) good_scaling: [ - (Potions, 0.005), // common.items.consumable.potion_minor - (Food, 0.15), // common.items.food.mushroom + (Food, 0.0375), // common.items.food.mushroom (Coin, 1.0), // common.items.utility.coins - (Armor, 0.5), // common.items.armor.misc.pants.worker_blue - (Tools, 0.5), // common.items.weapons.staff.starter_staff - (Ingredients, 0.5), // common.items.crafting_ing.leather_scraps + (Armor, 0.025), // common.items.armor.misc.pants.worker_blue + (Tools, 0.015487), // common.items.weapons.staff.starter_staff + (Ingredients, 0.034626), // common.items.crafting_ing.leather_scraps ]) diff --git a/common/src/comp/inventory/trade_pricing.rs b/common/src/comp/inventory/trade_pricing.rs index 32c269da85..d6b7b374be 100644 --- a/common/src/comp/inventory/trade_pricing.rs +++ b/common/src/comp/inventory/trade_pricing.rs @@ -1,5 +1,6 @@ use crate::{ assets::{self, AssetExt}, + comp::item::Item, lottery::LootSpec, recipe::{default_recipe_book, RecipeInput}, trade::Good, @@ -9,66 +10,208 @@ use hashbrown::HashMap; use lazy_static::lazy_static; use serde::Deserialize; use std::cmp::Ordering; -use tracing::{info, warn}; +use tracing::{error, info, warn}; const PRICING_DEBUG: bool = false; #[derive(Default, Debug)] pub struct TradePricing { - // items of different good kinds - tools: Entries, - armor: Entries, - potions: Entries, - food: Entries, - ingredients: Entries, - other: Entries, - - // good_scaling of coins - coin_scale: f32, - - // get amount of material per item - material_cache: HashMap, + items: PriceEntries, equality_set: EqualitySet, } -// item asset specifier, probability, whether it's sellable by merchants -type Entry = (String, f32, bool); +// combination logic: +// price is the inverse of frequency +// you can use either equivalent A or B => add frequency +// you need both equivalent A and B => add price -#[derive(Default, Debug)] -struct Entries { - entries: Vec, +/// Material equivalent for an item (price) +#[derive(Default, Debug, Clone)] +pub struct MaterialUse(Vec<(f32, Good)>); + +impl std::ops::Mul for MaterialUse { + type Output = Self; + + fn mul(self, rhs: f32) -> Self::Output { + Self(self.0.iter().map(|v| (v.0 * rhs, v.1)).collect()) + } } -impl Entries { - fn add(&mut self, eqset: &EqualitySet, item_name: &str, probability: f32, can_sell: bool) { - let canonical_itemname = eqset.canonical(item_name); - - let old = self - .entries +// used by the add variants +fn vector_add_eq(result: &mut Vec<(f32, Good)>, rhs: &[(f32, Good)]) { + for (amount, good) in rhs { + if result .iter_mut() - .find(|(name, _, _)| *name == *canonical_itemname); + .find(|(_amount2, good2)| *good == *good2) + .map(|elem| elem.0 += *amount) + .is_none() + { + result.push((*amount, *good)); + } + } +} - // Increase probability if already in entries, or add new entry - if let Some((asset, ref mut old_probability, _)) = old { - if PRICING_DEBUG { - info!("Update {} {}+{}", asset, old_probability, probability); - } - *old_probability += probability; +impl std::ops::Add for MaterialUse { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + let mut result = self; + vector_add_eq(&mut result.0, &rhs.0); + result + } +} + +impl std::ops::Deref for MaterialUse { + type Target = [(f32, Good)]; + + fn deref(&self) -> &Self::Target { self.0.deref() } +} + +/// Frequency +#[derive(Default, Debug, Clone)] +pub struct MaterialFrequency(Vec<(f32, Good)>); + +// to compute price from frequency: +// price[i] = 1/frequency[i] * 1/sum(frequency) * 1/sum(1/frequency) +// scaling individual components so that ratio is inverted and the sum of all +// inverted elements is equivalent to inverse of the original sum +fn vector_invert(result: &mut Vec<(f32, Good)>) { + let mut oldsum: f32 = 0.0; + let mut newsum: f32 = 0.0; + for (value, _good) in result.iter_mut() { + oldsum += *value; + *value = 1.0 / *value; + newsum += *value; + } + let scale = 1.0 / (oldsum * newsum); + for (value, _good) in result.iter_mut() { + *value *= scale; + } +} + +impl From for MaterialFrequency { + fn from(u: MaterialUse) -> Self { + let mut result = Self(u.0); + vector_invert(&mut result.0); + result + } +} + +// identical computation +impl From for MaterialUse { + fn from(u: MaterialFrequency) -> Self { + let mut result = Self(u.0); + vector_invert(&mut result.0); + result + } +} + +impl std::ops::Add for MaterialFrequency { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + let mut result = self; + vector_add_eq(&mut result.0, &rhs.0); + result + } +} + +impl std::ops::AddAssign for MaterialFrequency { + fn add_assign(&mut self, rhs: Self) { vector_add_eq(&mut self.0, &rhs.0); } +} + +#[derive(Default, Debug)] +struct PriceEntry { + // item asset specifier + name: String, + price: MaterialUse, + // sellable by merchants + sell: bool, + stackable: bool, +} +#[derive(Default, Debug)] +struct FreqEntry { + name: String, + freq: MaterialFrequency, + sell: bool, + stackable: bool, +} + +#[derive(Default, Debug)] +struct PriceEntries(Vec); +#[derive(Default, Debug)] +struct FreqEntries(Vec); + +impl PriceEntries { + fn add_alternative(&mut self, b: PriceEntry) { + // alternatives are added in frequency (gets more frequent) + let already = self.0.iter_mut().find(|i| i.name == b.name); + if let Some(entry) = already { + let entry_freq: MaterialFrequency = std::mem::take(&mut entry.price).into(); + let b_freq: MaterialFrequency = b.price.into(); + let result = entry_freq + b_freq; + entry.price = result.into(); } else { + self.0.push(b); + } + } +} + +impl FreqEntries { + fn add( + &mut self, + eqset: &EqualitySet, + item_name: &str, + good: Good, + probability: f32, + can_sell: bool, + ) { + let canonical_itemname = eqset.canonical(item_name); + let old = self + .0 + .iter_mut() + .find(|elem| elem.name == *canonical_itemname); + let new_freq = MaterialFrequency(vec![(probability, good)]); + // Increase probability if already in entries, or add new entry + if let Some(FreqEntry { + name: asset, + freq: ref mut old_probability, + sell: old_can_sell, + stackable: _, + }) = old + { if PRICING_DEBUG { - info!("New {} {}", item_name, probability); + info!("Update {} {:?}+{:?}", asset, old_probability, probability); } - self.entries - .push((canonical_itemname.to_owned(), probability, can_sell)); + if !can_sell && *old_can_sell { + *old_can_sell = false; + } + *old_probability += new_freq; + } else { + let item = Item::new_from_asset_expect(canonical_itemname); + let stackable = item.is_stackable(); + let new_mat_prob: FreqEntry = FreqEntry { + name: canonical_itemname.to_owned(), + freq: new_freq, + sell: can_sell, + stackable, + }; + if PRICING_DEBUG { + info!("New {:?}", new_mat_prob); + } + self.0.push(new_mat_prob); } // Add the non-canonical item so that it'll show up in merchant inventories // It will have infinity as its price, but it's fine, // because we determine all prices based on canonical value - if canonical_itemname != item_name - && !self.entries.iter().any(|(name, _, _)| name == item_name) - { - self.entries.push((item_name.to_owned(), 0.0, can_sell)); + if canonical_itemname != item_name && !self.0.iter().any(|elem| elem.name == *item_name) { + self.0.push(FreqEntry { + name: item_name.to_owned(), + freq: Default::default(), + sell: can_sell, + stackable: false, + }); } } } @@ -197,27 +340,10 @@ impl assets::Compound for EqualitySet { struct RememberedRecipe { output: String, amount: u32, - material_cost: f32, + material_cost: Option, input: Vec<(String, u32)>, } -fn sort_and_normalize(entryvec: &mut [Entry], scale: f32) { - if !entryvec.is_empty() { - entryvec.sort_by(|a, b| { - a.1.partial_cmp(&b.1) - .unwrap_or(Ordering::Equal) - .then_with(|| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal)) - }); - if let Some((_, max_scale, _)) = entryvec.last() { - // most common item has frequency max_scale. avoid NaN - let rescale = scale / max_scale; - for i in entryvec.iter_mut() { - i.1 *= rescale; - } - } - } -} - fn get_scaling(contents: &AssetGuard, good: Good) -> f32 { contents .good_scaling @@ -231,135 +357,109 @@ impl TradePricing { const CRAFTING_FACTOR: f32 = 0.95; // increase price a bit compared to sum of ingredients const INVEST_FACTOR: f32 = 0.33; - const UNAVAILABLE_PRICE: f32 = 1_000_000.0; - // add this much of a non-consumed crafting tool price - - fn get_list(&self, good: Good) -> &[Entry] { - match good { - Good::Armor => &self.armor.entries, - Good::Tools => &self.tools.entries, - Good::Potions => &self.potions.entries, - Good::Food => &self.food.entries, - Good::Ingredients => &self.ingredients.entries, - _ => &[], - } - } - - fn get_list_mut(&mut self, good: Good) -> &mut [Entry] { - match good { - Good::Armor => &mut self.armor.entries, - Good::Tools => &mut self.tools.entries, - Good::Potions => &mut self.potions.entries, - Good::Food => &mut self.food.entries, - Good::Ingredients => &mut self.ingredients.entries, - _ => &mut [], - } - } - - fn get_list_by_path(&self, name: &str) -> &[Entry] { + fn good_from_item(name: &str) -> Good { match name { - // Armor - _ if name.starts_with("common.items.armor.") => &self.armor.entries, - // Tools - _ if name.starts_with("common.items.weapons.") => &self.tools.entries, - _ if name.starts_with("common.items.tool.") => &self.tools.entries, - // Ingredients - _ if name.starts_with("common.items.crafting_ing.") => &self.ingredients.entries, - _ if name.starts_with("common.items.mineral.") => &self.ingredients.entries, - _ if name.starts_with("common.items.flowers.") => &self.ingredients.entries, - // Potions - _ if name.starts_with("common.items.consumable.") => &self.potions.entries, - // Food - _ if name.starts_with("common.items.food.") => &self.food.entries, - // Other - _ if name.starts_with("common.items.glider.") => &self.other.entries, - _ if name.starts_with("common.items.utility.") => &self.other.entries, - _ if name.starts_with("common.items.boss_drops.") => &self.other.entries, - _ if name.starts_with("common.items.crafting_tools.") => &self.other.entries, - _ if name.starts_with("common.items.lantern.") => &self.other.entries, + _ if name.starts_with("common.items.armor.") => Good::Armor, + + _ if name.starts_with("common.items.weapons.") => Good::Tools, + _ if name.starts_with("common.items.tool.") => Good::Tools, + + _ if name.starts_with("common.items.crafting_ing.") => Good::Ingredients, + _ if name.starts_with("common.items.mineral.") => Good::Ingredients, + _ if name.starts_with("common.items.flowers.") => Good::Ingredients, + + _ if name.starts_with("common.items.consumable.") => Good::Potions, + + _ if name.starts_with("common.items.food.") => Good::Food, + + Self::COIN_ITEM => Good::Coin, + + _ if name.starts_with("common.items.glider.") => Good::default(), + _ if name.starts_with("common.items.utility.") => Good::default(), + _ if name.starts_with("common.items.boss_drops.") => Good::default(), + _ if name.starts_with("common.items.crafting_tools.") => Good::default(), + _ if name.starts_with("common.items.lantern.") => Good::default(), _ => { warn!("unknown loot item {}", name); - &self.other.entries - }, - } - } - - fn get_list_by_path_mut(&mut self, name: &str) -> &mut Entries { - match name { - // Armor - _ if name.starts_with("common.items.armor.") => &mut self.armor, - // Tools - _ if name.starts_with("common.items.weapons.") => &mut self.tools, - _ if name.starts_with("common.items.tool.") => &mut self.tools, - // Ingredients - _ if name.starts_with("common.items.crafting_ing.") => &mut self.ingredients, - _ if name.starts_with("common.items.mineral.") => &mut self.ingredients, - _ if name.starts_with("common.items.flowers.") => &mut self.ingredients, - // Potions - _ if name.starts_with("common.items.consumable.") => &mut self.potions, - // Food - _ if name.starts_with("common.items.food.") => &mut self.food, - // Other - _ if name.starts_with("common.items.glider.") => &mut self.other, - _ if name.starts_with("common.items.utility.") => &mut self.other, - _ if name.starts_with("common.items.boss_drops.") => &mut self.other, - _ if name.starts_with("common.items.crafting_tools.") => &mut self.other, - _ if name.starts_with("common.items.lantern.") => &mut self.other, - _ => { - warn!("unknown loot item {}", name); - &mut self.other + Good::default() }, } } // look up price (inverse frequency) of an item - fn price_lookup(&self, eqset: &EqualitySet, requested_name: &str) -> f32 { - let canonical_name = eqset.canonical(requested_name); - - let goods = self.get_list_by_path(canonical_name); - // even if we multiply by INVEST_FACTOR we need to remain - // above UNAVAILABLE_PRICE (add 1.0 to compensate rounding errors) - goods + fn price_lookup(&self, requested_name: &str) -> Option<&MaterialUse> { + let canonical_name = self.equality_set.canonical(requested_name); + self.items + .0 .iter() - .find(|(name, _, _)| name == canonical_name) - .map_or( - Self::UNAVAILABLE_PRICE / Self::INVEST_FACTOR + 1.0, - |(_, freq, _)| 1.0 / freq, - ) + .find(|e| e.name == canonical_name) + .map(|e| &e.price) } - #[allow(clippy::cast_precision_loss)] - fn calculate_material_cost(&self, r: &RememberedRecipe, eqset: &EqualitySet) -> f32 { + fn calculate_material_cost(&self, r: &RememberedRecipe) -> Option { r.input .iter() .map(|(name, amount)| { - self.price_lookup(eqset, name) * (*amount as f32).max(Self::INVEST_FACTOR) + self.price_lookup(name).map(|x| { + x.clone() + * (if *amount > 0 { + *amount as f32 + } else { + Self::INVEST_FACTOR + }) + }) }) - .sum() + .try_fold(MaterialUse::default(), |acc, elem| Some(acc + elem?)) + } + + fn calculate_material_cost_sum(&self, r: &RememberedRecipe) -> Option { + self.calculate_material_cost(r)? + .iter() + .fold(None, |acc, elem| Some(acc.unwrap_or_default() + elem.0)) } // re-look up prices and sort the vector by ascending material cost, return // whether first cost is finite - fn sort_by_price(&self, recipes: &mut Vec, eqset: &EqualitySet) -> bool { + fn sort_by_price(&self, recipes: &mut Vec) -> bool { for recipe in recipes.iter_mut() { - recipe.material_cost = self.calculate_material_cost(recipe, eqset); + recipe.material_cost = self.calculate_material_cost_sum(recipe); } - recipes.sort_by(|a, b| a.material_cost.partial_cmp(&b.material_cost).unwrap()); - //info!(?recipes); + // put None to the end + recipes.sort_by(|a, b| { + if a.material_cost.is_some() { + if b.material_cost.is_some() { + a.material_cost + .partial_cmp(&b.material_cost) + .unwrap_or(Ordering::Equal) + } else { + Ordering::Less + } + } else if b.material_cost.is_some() { + Ordering::Greater + } else { + Ordering::Equal + } + }); + if PRICING_DEBUG { + tracing::debug!("{:?}", recipes); + } + //info!(? recipes); recipes .first() - .filter(|recipe| recipe.material_cost < Self::UNAVAILABLE_PRICE) + .filter(|recipe| recipe.material_cost.is_some()) .is_some() } - #[allow(clippy::cast_precision_loss)] + // #[allow(clippy::cast_precision_loss)] fn read() -> Self { let mut result = Self::default(); + let mut freq = FreqEntries::default(); let price_config = TradingPriceFile::load_expect("common.trading.item_price_calculation").read(); - let eqset = EqualitySet::load_expect("common.trading.item_price_equality").read(); - result.equality_set = eqset.clone(); + result.equality_set = EqualitySet::load_expect("common.trading.item_price_equality") + .read() + .clone(); for table in &price_config.loot_tables { if PRICING_DEBUG { info!(?table); @@ -367,14 +467,55 @@ impl TradePricing { let (frequency, can_sell, asset_path) = table; let loot = ProbabilityFile::load_expect(asset_path); for (p, item_asset, amount) in &loot.read().content { - result.get_list_by_path_mut(item_asset).add( - &eqset, + let good = Self::good_from_item(item_asset); + let scaling = get_scaling(&price_config, good); + freq.add( + &result.equality_set, item_asset, - frequency * p * *amount, + good, + frequency * p * *amount * scaling, *can_sell, ); } } + freq.add( + &result.equality_set, + Self::COIN_ITEM, + Good::Coin, + get_scaling(&price_config, Good::Coin), + true, + ); + // convert frequency to price + result.items.0.extend(freq.0.iter().map(|elem| { + if elem.freq.0.is_empty() { + // likely equality + let canonical_name = result.equality_set.canonical(&elem.name); + let can_freq = freq.0.iter().find(|i| i.name == canonical_name); + can_freq + .map(|e| PriceEntry { + name: elem.name.clone(), + price: MaterialUse::from(e.freq.clone()), + sell: elem.sell && e.sell, + stackable: elem.stackable, + }) + .unwrap_or(PriceEntry { + name: elem.name.clone(), + price: MaterialUse::from(elem.freq.clone()), + sell: elem.sell, + stackable: elem.stackable, + }) + } else { + PriceEntry { + name: elem.name.clone(), + price: MaterialUse::from(elem.freq.clone()), + sell: elem.sell, + stackable: elem.stackable, + } + } + })); + if PRICING_DEBUG { + tracing::debug!("{:?}", result.items.0); + } // Apply recipe book let book = default_recipe_book().read(); @@ -384,7 +525,7 @@ impl TradePricing { ordered_recipes.push(RememberedRecipe { output: asset_path.id().into(), amount, - material_cost: Self::UNAVAILABLE_PRICE, + material_cost: None, input: recipe .inputs .iter() @@ -406,106 +547,130 @@ impl TradePricing { // re-evaluate prices based on crafting tables // (start with cheap ones to avoid changing material prices after evaluation) - while result.sort_by_price(&mut ordered_recipes, &eqset) { + while result.sort_by_price(&mut ordered_recipes) { ordered_recipes.retain(|recipe| { - if recipe.material_cost < 1e-5 { + if recipe.material_cost.map_or(false, |p| p < 1e-5) { + // don't handle recipes which have no raw materials false - } else if recipe.material_cost < Self::UNAVAILABLE_PRICE { - let actual_cost = result.calculate_material_cost(recipe, &eqset); - let output_tradeable = recipe.input.iter().all(|(input, _)| { - result - .get_list_by_path(input) - .iter() - .find(|(item, _, _)| item == input) - .map_or(false, |(_, _, tradeable)| *tradeable) - }); - result.get_list_by_path_mut(&recipe.output).add( - &eqset, - &recipe.output, - (recipe.amount as f32) / actual_cost * Self::CRAFTING_FACTOR, - output_tradeable, - ); + } else if recipe.material_cost.is_some() { + let actual_cost = result.calculate_material_cost(recipe); + if let Some(usage) = actual_cost { + let output_tradeable = recipe.input.iter().all(|(input, _)| { + result + .items + .0 + .iter() + .find(|item| item.name == *input) + .map_or(false, |item| item.sell) + }); + let item = Item::new_from_asset_expect(&recipe.output); + let stackable = item.is_stackable(); + result.items.add_alternative(PriceEntry { + name: recipe.output.clone(), + price: usage * (recipe.amount as f32) * Self::CRAFTING_FACTOR, + sell: output_tradeable, + stackable, + }); + } else { + error!("Recipe {:?} incomplete confusion", recipe); + } false } else { + // handle incomplete recipes later true } }); //info!(?ordered_recipes); } - - let good_list = [ - Good::Armor, - Good::Tools, - Good::Potions, - Good::Food, - Good::Ingredients, - ]; - - for good in &good_list { - sort_and_normalize( - result.get_list_mut(*good), - get_scaling(&price_config, *good), - ); - let mut materials = result - .get_list(*good) - .iter() - .map(|i| (i.0.clone(), (*good, 1.0 / i.1))) - .collect::>(); - result.material_cache.extend(materials.drain(..)); - } - result.coin_scale = get_scaling(&price_config, Good::Coin); result } - #[allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss - )] - fn random_item_impl(&self, good: Good, amount: f32, selling: bool) -> Option { - if good == Good::Coin { - Some(Self::COIN_ITEM.into()) - } else { - let table = self.get_list(good); - if table.is_empty() - || (selling && table.iter().filter(|(_, _, can_sell)| *can_sell).count() == 0) - { - warn!("Good: {:?}, was unreachable.", good); - return None; - } - let upper = table.len(); - let lower = table - .iter() - .enumerate() - .find(|i| i.1.1 * amount >= 1.0) - .map_or(upper - 1, |i| i.0); - loop { - let index = - (rand::random::() * ((upper - lower) as f32)).floor() as usize + lower; - if table.get(index).map_or(false, |i| !selling || i.2) { - break table.get(index).map(|i| i.0.clone()); - } + // TODO: optimize repeated use + fn random_items_impl( + &self, + stockmap: &mut HashMap, + mut number: u32, + selling: bool, + always_coin: bool, + limit: u32, + ) -> Vec<(String, u32)> { + let mut candidates: Vec<&PriceEntry> = self + .items + .0 + .iter() + .filter(|i| { + let excess = i + .price + .iter() + .find(|j| j.0 >= stockmap.get(&j.1).cloned().unwrap_or_default()); + excess.is_none() + && (!selling || i.sell) + && (!always_coin || i.name != Self::COIN_ITEM) + }) + .collect(); + let mut result = Vec::new(); + if always_coin && number > 0 { + let amount = stockmap.get(&Good::Coin).copied().unwrap_or_default() as u32; + if amount > 0 { + result.push((Self::COIN_ITEM.into(), amount)); + number -= 1; } } + for _ in 0..number { + candidates.retain(|i| { + let excess = i + .price + .iter() + .find(|j| j.0 >= stockmap.get(&j.1).cloned().unwrap_or_default()); + excess.is_none() + }); + if candidates.is_empty() { + break; + } + let index = (rand::random::() * candidates.len() as f32).floor() as usize; + let result2 = candidates[index]; + let amount: u32 = if result2.stackable { + let max_amount = result2 + .price + .iter() + .map(|e| { + stockmap + .get_mut(&e.1) + .map(|stock| *stock / e.0.max(0.001)) + .unwrap_or_default() + }) + .fold(f32::INFINITY, f32::min) + .min(limit as f32); + (rand::random::() * (max_amount - 1.0)).floor() as u32 + 1 + } else { + 1 + }; + for i in result2.price.iter() { + stockmap.get_mut(&i.1).map(|v| *v -= i.0 * (amount as f32)); + } + result.push((result2.name.clone(), amount)); + // avoid duplicates + candidates.remove(index); + } + result + } + + fn get_materials_impl(&self, item: &str) -> Option<&MaterialUse> { self.price_lookup(item) } + + #[must_use] + pub fn random_items( + stock: &mut HashMap, + number: u32, + selling: bool, + always_coin: bool, + limit: u32, + ) -> Vec<(String, u32)> { + TRADE_PRICING.random_items_impl(stock, number, selling, always_coin, limit) } #[must_use] - pub fn random_item(good: Good, amount: f32, selling: bool) -> Option { - TRADE_PRICING.random_item_impl(good, amount, selling) - } - - #[must_use] - pub fn get_material(item: &str) -> (Good, f32) { - if item == Self::COIN_ITEM { - (Good::Coin, 1.0) - } else { - let item = TRADE_PRICING.equality_set.canonical(item); - - TRADE_PRICING.material_cache.get(item).copied().map_or( - (Good::Terrain(crate::terrain::BiomeKind::Void), 0.0), - |(a, b)| (a, b * TRADE_PRICING.coin_scale), - ) - } + pub fn get_materials(item: &str) -> Option<&MaterialUse> { + TRADE_PRICING.get_materials_impl(item) } #[cfg(test)] @@ -513,69 +678,32 @@ impl TradePricing { #[cfg(test)] fn print_sorted(&self) { - use crate::comp::item::{armor, tool, Item, ItemKind}; - - // we pass the item and the inverse of the price to the closure - fn printvec(good_kind: &str, entries: &[(String, f32, bool)], f: F, unit: &str) - where - F: Fn(&Item, f32) -> String, - { - for (item_id, p, can_sell) in entries.iter() { - let it = Item::new_from_asset_expect(item_id); - let price = 1.0 / p; - println!( - "{}, {}, {:>4.2}, {}, {:?}, {}, {},", - item_id, - if *can_sell { "yes" } else { "no" }, - price, - good_kind, - it.quality, - f(&it, *p), - unit, - ); - } - } + use crate::comp::item::{armor, tool, ItemKind}; println!("Item, ForSale, Amount, Good, Quality, Deal, Unit,"); - printvec( - "Armor", - &self.armor.entries, - |i, p| { - if let ItemKind::Armor(a) = &i.kind { + fn more_information(i: &Item, p: f32) -> (String, &'static str) { + if let ItemKind::Armor(a) = &i.kind { + ( match a.protection() { Some(armor::Protection::Invincible) => "Invincible".into(), Some(armor::Protection::Normal(x)) => format!("{:.4}", x * p), None => "0.0".into(), - } - } else { - format!("{:?}", i.kind) - } - }, - "prot/val", - ); - printvec( - "Tools", - &self.tools.entries, - |i, p| { - if let ItemKind::Tool(t) = &i.kind { + }, + "prot/val", + ) + } else if let ItemKind::Tool(t) = &i.kind { + ( match &t.stats { tool::StatKind::Direct(d) => { format!("{:.4}", d.power * d.speed * p) }, tool::StatKind::Modular => "Modular".into(), - } - } else { - format!("{:?}", i.kind) - } - }, - "dps/val", - ); - printvec( - "Potions", - &self.potions.entries, - |i, p| { - if let ItemKind::Consumable { kind: _, effects } = &i.kind { + }, + "dps/val", + ) + } else if let ItemKind::Consumable { kind: _, effects } = &i.kind { + ( effects .iter() .map(|e| { @@ -586,43 +714,53 @@ impl TradePricing { } }) .collect::>() - .join(" ") - } else { - format!("{:?}", i.kind) - } + .join(" "), + "str/val", + ) + } else { + (Default::default(), "") + } + } + let mut sorted: Vec<(f32, &PriceEntry)> = self + .items + .0 + .iter() + .map(|e| (e.price.iter().map(|i| i.0.to_owned()).sum(), e)) + .collect(); + sorted.sort_by(|(p, e), (p2, e2)| { + p2.partial_cmp(p) + .unwrap_or(Ordering::Equal) + .then(e.name.cmp(&e2.name)) + }); + + for ( + pricesum, + PriceEntry { + name: item_id, + price: mat_use, + sell: can_sell, + stackable: _, }, - "str/val", - ); - printvec( - "Food", - &self.food.entries, - |i, p| { - if let ItemKind::Consumable { kind: _, effects } = &i.kind { - effects - .iter() - .map(|e| { - if let crate::effect::Effect::Buff(b) = e { - format!("{:.2}", b.data.strength * p) - } else { - format!("{:?}", e) - } - }) - .collect::>() - .join(" ") - } else { - format!("{:?}", i.kind) - } - }, - "str/val", - ); - printvec( - "Ingredients", - &self.ingredients.entries, - |_i, _p| String::new(), - "", - ); - printvec("Other", &self.other.entries, |_i, _p| String::new(), ""); - println!("{}, yes, {}, Coin, ,,,", Self::COIN_ITEM, self.coin_scale); + ) in sorted.iter() + { + let it = Item::new_from_asset_expect(item_id); + //let price = mat_use.iter().map(|(amount, _good)| *amount).sum::(); + let prob = 1.0 / pricesum; + let (info, unit) = more_information(&it, prob); + let materials = mat_use + .iter() + .fold(String::new(), |agg, i| agg + &format!("{:?}.", i.1)); + println!( + "{}, {}, {:>4.2}, {}, {:?}, {}, {},", + &item_id, + if *can_sell { "yes" } else { "no" }, + pricesum, + materials, + it.quality, + info, + unit, + ); + } } } @@ -684,10 +822,19 @@ mod tests { init(); info!("init"); - for _ in 0..5 { - if let Some(item_id) = TradePricing::random_item(Good::Armor, 5.0, false) { - info!("Armor 5 {}", item_id); - } + let mut stock: hashbrown::HashMap = vec![ + (Good::Ingredients, 50.0), + (Good::Tools, 10.0), + (Good::Armor, 10.0), + //(Good::Ores, 20.0), + ] + .iter() + .copied() + .collect(); + + let loadout = TradePricing::random_items(&mut stock, 20, false, false, 999); + for i in loadout.iter() { + info!("Random item {}*{}", i.0, i.1); } } diff --git a/common/src/trade.rs b/common/src/trade.rs index f37ac12ef7..42a807d1ff 100644 --- a/common/src/trade.rs +++ b/common/src/trade.rs @@ -367,11 +367,18 @@ impl SitePrices { .as_ref() .and_then(|ri| { ri.inventory.get(slot).map(|item| { - let (material, factor) = TradePricing::get_material(&item.name); - self.values.get(&material).cloned().unwrap_or_default() - * factor - * (*amount as f32) - * if reduce { material.trade_margin() } else { 1.0 } + if let Some(vec) = TradePricing::get_materials(&item.name) { + vec.iter() + .map(|(amount2, material)| { + self.values.get(material).copied().unwrap_or_default() + * *amount2 + * (if reduce { material.trade_margin() } else { 1.0 }) + }) + .sum::() + * (*amount as f32) + } else { + 0.0 + } }) }) .unwrap_or_default() diff --git a/voxygen/src/hud/mod.rs b/voxygen/src/hud/mod.rs index 4a551d25a1..a56f252362 100644 --- a/voxygen/src/hud/mod.rs +++ b/voxygen/src/hud/mod.rs @@ -3730,28 +3730,38 @@ impl Hud { false, ); if let Some(item) = inventory.get(slot) { - let (material, factor) = - TradePricing::get_material(item.item_definition_id()); - let mut unit_price = prices - .values - .get(&material) - .cloned() - .unwrap_or_default() - * factor; - if ours { - unit_price *= material.trade_margin(); + if let Some(materials) = + TradePricing::get_materials(item.item_definition_id()) + { + let unit_price: f32 = materials + .iter() + .map(|e| { + prices + .values + .get(&e.1) + .cloned() + .unwrap_or_default() + * e.0 + * (if ours { + e.1.trade_margin() + } else { + 1.0 + }) + }) + .sum(); + + let mut float_delta = if ours ^ remove { + (balance1 - balance0) / unit_price + } else { + (balance0 - balance1) / unit_price + }; + if ours ^ remove { + float_delta = float_delta.ceil(); + } else { + float_delta = float_delta.floor(); + } + *quantity = float_delta.max(0.0) as u32; } - let mut float_delta = if ours ^ remove { - (balance1 - balance0) / unit_price - } else { - (balance0 - balance1) / unit_price - }; - if ours ^ remove { - float_delta = float_delta.ceil(); - } else { - float_delta = float_delta.floor(); - } - *quantity = float_delta.max(0.0) as u32; } } }; diff --git a/voxygen/src/hud/util.rs b/voxygen/src/hud/util.rs index 44c0737ce4..78325df3a8 100644 --- a/voxygen/src/hud/util.rs +++ b/voxygen/src/hud/util.rs @@ -22,34 +22,49 @@ pub fn price_desc( i18n: &Localization, ) -> Option<(String, String, f32)> { if let Some(prices) = prices { - let (material, factor) = TradePricing::get_material(item_definition_id); - let coinprice = prices.values.get(&Good::Coin).cloned().unwrap_or(1.0); - let buyprice = prices.values.get(&material).cloned().unwrap_or_default() * factor; - let sellprice = buyprice * material.trade_margin(); + if let Some(materials) = TradePricing::get_materials(item_definition_id) { + let coinprice = prices.values.get(&Good::Coin).cloned().unwrap_or(1.0); + let buyprice: f32 = materials + .iter() + .map(|e| prices.values.get(&e.1).cloned().unwrap_or_default() * e.0) + .sum(); + let sellprice: f32 = materials + .iter() + .map(|e| { + prices.values.get(&e.1).cloned().unwrap_or_default() * e.0 * e.1.trade_margin() + }) + .sum(); - let deal_goodness = prices.values.get(&material).cloned().unwrap_or(0.0) - / prices.values.get(&Good::Coin).cloned().unwrap_or(1.0); - let deal_goodness = deal_goodness.log(2.0); - let buy_string = format!( - "{} : {:0.1} {}", - i18n.get("hud.trade.buy_price"), - buyprice / coinprice, - i18n.get("hud.trade.coin"), - ); - let sell_string = format!( - "{} : {:0.1} {}", - i18n.get("hud.trade.sell_price"), - sellprice / coinprice, - i18n.get("hud.trade.coin"), - ); - let deal_goodness = match deal_goodness { - x if x < -2.5 => 0.0, - x if x < -1.05 => 0.25, - x if x < -0.95 => 0.5, - x if x < 0.0 => 0.75, - _ => 1.0, - }; - Some((buy_string, sell_string, deal_goodness)) + let deal_goodness: f32 = materials + .iter() + .map(|e| prices.values.get(&e.1).cloned().unwrap_or(0.0)) + .sum::() + / prices.values.get(&Good::Coin).cloned().unwrap_or(1.0) + / (materials.len() as f32); + let deal_goodness = deal_goodness.log(2.0); + let buy_string = format!( + "{} : {:0.1} {}", + i18n.get("hud.trade.buy_price"), + buyprice / coinprice, + i18n.get("hud.trade.coin"), + ); + let sell_string = format!( + "{} : {:0.1} {}", + i18n.get("hud.trade.sell_price"), + sellprice / coinprice, + i18n.get("hud.trade.coin"), + ); + let deal_goodness = match deal_goodness { + x if x < -2.5 => 0.0, + x if x < -1.05 => 0.25, + x if x < -0.95 => 0.5, + x if x < 0.0 => 0.75, + _ => 1.0, + }; + Some((buy_string, sell_string, deal_goodness)) + } else { + None + } } else { None } diff --git a/world/src/site/settlement/mod.rs b/world/src/site/settlement/mod.rs index 5a89a888e6..87e7a82b35 100644 --- a/world/src/site/settlement/mod.rs +++ b/world/src/site/settlement/mod.rs @@ -35,12 +35,7 @@ use fxhash::FxHasher64; use hashbrown::{HashMap, HashSet}; use rand::prelude::*; use serde::Deserialize; -use std::{ - cmp::{self, min}, - collections::VecDeque, - f32, - hash::BuildHasherDefault, -}; +use std::{collections::VecDeque, f32, hash::BuildHasherDefault}; use vek::*; #[derive(Deserialize)] @@ -1019,236 +1014,96 @@ pub fn merchant_loadout( ) -> LoadoutBuilder { let rng = &mut rand::thread_rng(); - // Fill backpack with ingredients and coins - let backpack = ingredient_backpack(economy, rng); - - // Fill bags with stuff - let (food_bag, potion_bag) = consumable_bags(economy, rng); - let weapon_bag = weapon_bag(economy); - let armor_bag = armor_bag(economy); - - loadout_builder - .with_asset_expect("common.loadout.village.merchant", rng) - .back(Some(backpack)) - .bag(ArmorSlot::Bag1, Some(food_bag)) - .bag(ArmorSlot::Bag2, Some(potion_bag)) - .bag(ArmorSlot::Bag3, Some(weapon_bag)) - .bag(ArmorSlot::Bag4, Some(armor_bag)) -} - -fn sort_bag(bag: &mut Item) { - bag.slots_mut().sort_by(|a, b| match (a, b) { - (Some(a), Some(b)) => a.quality().cmp(&b.quality()), - (None, Some(_)) => cmp::Ordering::Greater, - (Some(_), None) => cmp::Ordering::Less, - (None, None) => cmp::Ordering::Equal, - }); -} - -fn armor_bag(economy: Option<&trade::SiteInformation>) -> Item { - #![warn(clippy::pedantic)] - - let mut bag = Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack"); - - let armor_items = economy - .and_then(|e| e.unconsumed_stock.get(&Good::Armor)) - .copied(); - - // If we have some uncomsumed armor, stock - if let Some(armor_good) = armor_items { - if armor_good < f32::EPSILON { - return bag; - } - for slot in bag.slots_mut() { - let amount = armor_good / 10.0; - if let Some(item_id) = TradePricing::random_item(Good::Armor, amount, true) { - *slot = Some(Item::new_from_asset_expect(&item_id)); - } - } - } - sort_bag(&mut bag); - - bag -} - -fn weapon_bag(economy: Option<&trade::SiteInformation>) -> Item { - #![warn(clippy::pedantic)] - - let mut bag = Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack"); - - let weapons = economy - .and_then(|e| e.unconsumed_stock.get(&Good::Tools)) - .copied(); - - // If we have some uncomsumed weapons, stock - if let Some(weapon_good) = weapons { - if weapon_good < f32::EPSILON { - return bag; - } - for slot in bag.slots_mut() { - let amount = weapon_good / 10.0; - if let Some(item_id) = TradePricing::random_item(Good::Tools, amount, true) { - *slot = Some(Item::new_from_asset_expect(&item_id)); - } - } - } - sort_bag(&mut bag); - - bag -} - -fn ingredient_backpack(economy: Option<&trade::SiteInformation>, rng: &mut impl Rng) -> Item { - #![warn(clippy::pedantic)] - - let mut bag = Item::new_from_asset_expect("common.items.armor.merchant.back"); - let slots = bag.slots_mut(); - - // It's safe to truncate here, because coins clamped to 3000 max - // also we don't really want negative values here - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let coins = economy - .and_then(|e| e.unconsumed_stock.get(&Good::Coin)) - .copied() - .map(|cs| cs.min(rand::thread_rng().gen_range(1000.0..3000.0)) as u32); - let ingredients = economy - .and_then(|e| e.unconsumed_stock.get(&Good::Ingredients)) - .copied(); - - // `to_skip` is ideologicaly boolean flag either to start from 0-th or 1-th slot - let mut to_skip = 0; - if let Some(coins) = coins.filter(|c| *c > 0) { - let mut coin_item = Item::new_from_asset_expect("common.items.utility.coins"); - coin_item - .set_amount(coins) - .expect("coins should be stackable"); - - if let Some(slot) = slots.first_mut() { - *slot = Some(coin_item); - } else { - common_base::dev_panic!("Merchant backpack doesn't have slots"); - } - // as we've placed coins, start from second slot - to_skip = 1; - } - - // If we have some uncomsumed ingredients, trade - if let Some(ingredients_good) = ingredients { - let mut supply = ingredients_good / 10.0; - let tries = slots.len() - to_skip; - let good_map = gather_merged_goods(Good::Ingredients, &mut supply, tries, rng); - - for (i, item) in good_map.into_iter().enumerate() { - // As we have at most (slots.len() - to_skip) items - // index won't panic - slots[i + to_skip] = Some(item); - } - } - sort_bag(&mut bag); - - bag -} - -fn consumable_bags(economy: Option<&trade::SiteInformation>, rng: &mut impl Rng) -> (Item, Item) { - #![warn(clippy::pedantic)] - - let (mut bag3, mut bag4) = ( - Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack"), - Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack"), - ); + let mut backpack = Item::new_from_asset_expect("common.items.armor.misc.back.backpack"); + let mut bag1 = Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack"); + let mut bag2 = Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack"); + let mut bag3 = Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack"); + let mut bag4 = Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack"); + let slots = backpack.slots().len() + 4 * bag1.slots().len(); + let mut stockmap: HashMap = economy + .map(|e| e.unconsumed_stock.clone()) + .unwrap_or_default(); + // modify stock for better gameplay // TODO: currently econsim spends all its food on population, resulting in none // for the players to buy; the `.max` is temporary to ensure that there's some // food for sale at every site, to be used until we have some solution like NPC // houses as a limit on econsim population growth - let mut food = economy - .and_then(|e| e.unconsumed_stock.get(&Good::Food)) - .copied() - .map_or(Some(10_000.0), |food| Some(food.max(10_000.0))); + stockmap + .entry(Good::Food) + .and_modify(|e| *e = e.max(10_000.0)) + .or_insert(10_000.0); // Reduce amount of potions so merchants do not oversupply potions. // TODO: Maybe remove when merchants and their inventories are rtsim? - let mut potions = economy - .and_then(|e| e.unconsumed_stock.get(&Good::Potions)) - .copied() - .map(|potions| potions.powf(0.25)); + // Note: Likely without effect now that potions are counted as food + stockmap + .entry(Good::Potions) + .and_modify(|e| *e = e.powf(0.25)); + // It's safe to truncate here, because coins clamped to 3000 max + // also we don't really want negative values here + stockmap + .entry(Good::Coin) + .and_modify(|e| *e = e.min(rng.gen_range(1000.0..3000.0))); + // assume roughly 10 merchants sharing a town's stock (other logic for coins) + stockmap + .iter_mut() + .filter(|(good, _amount)| **good != Good::Coin) + .for_each(|(_good, amount)| *amount *= 0.1); + // Fill bags with stuff according to unclaimed stock + let mut wares: Vec = + TradePricing::random_items(&mut stockmap, slots as u32, true, true, 16) + .iter() + .map(|(n, a)| { + let mut i = Item::new_from_asset_expect(n); + i.set_amount(*a) + .map_err(|_| tracing::error!("merchant loadout amount failure")) + .ok(); + i + }) + .collect(); + sort_wares(&mut wares); + transfer(&mut wares, &mut backpack); + transfer(&mut wares, &mut bag1); + transfer(&mut wares, &mut bag2); + transfer(&mut wares, &mut bag3); + transfer(&mut wares, &mut bag4); - let goods = [ - (Good::Food, &mut food, &mut bag3), - (Good::Potions, &mut potions, &mut bag4), - ]; - - for (good_kind, goods, bag) in goods { - // Try to get goods as many times as we have slots - let mut supply = goods.unwrap_or(0.0) / 10.0; - let tries = bag.slots().len(); - let good_map = gather_merged_goods(good_kind, &mut supply, tries, rng); - - // Place them to the bags - let slots = bag.slots_mut(); - for (i, item) in good_map.into_iter().enumerate() { - // As we have at most `slots.len()` items - // index won't panic - slots[i] = Some(item); - } - sort_bag(bag); - } - (bag3, bag4) + loadout_builder + .with_asset_expect("common.loadout.village.merchant", rng) + .back(Some(backpack)) + .bag(ArmorSlot::Bag1, Some(bag1)) + .bag(ArmorSlot::Bag2, Some(bag2)) + .bag(ArmorSlot::Bag3, Some(bag3)) + .bag(ArmorSlot::Bag4, Some(bag4)) } -/// Returns vector of `tries` to gather given `good_kind` with given `supply` -/// Merges items if possible -#[warn(clippy::pedantic)] -fn gather_merged_goods( - good_kind: Good, - supply: &mut f32, - tries: usize, - rng: &mut impl Rng, -) -> Vec { - let mut good_map: Vec = Vec::new(); - // NOTE: Conversion to and from f32 works fine - // because we make sure the - // number we're converting is 1..100. - #[allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss - )] - for _ in 0..tries { - if let Some(item_id) = TradePricing::random_item(good_kind, *supply, true) { - if *supply > 0.0 { - // clamp it with 1..n items per slot - // where n < stack_size, n < 16, n < supply - let mut new_item = Item::new_from_asset_expect(&item_id); - let supported_amount = new_item.max_amount(); - let max = min(*supply as u32, min(16, supported_amount)); - let n = if new_item.is_stackable() { - rng.gen_range(1..cmp::max(2, max)) - } else { - 1 - }; +fn sort_wares(bag: &mut Vec) { + use common::comp::item::TagExampleInfo; - // Try to merge with item we already have - let old_item = good_map.iter_mut().find(|old_item| { - old_item.item_definition_id() == new_item.item_definition_id() - }); - let mut updated = false; - if let Some(item) = old_item { - if item.set_amount(item.amount() + n).is_ok() { - updated = true; - } - } + bag.sort_by(|a, b| { + a.quality() + .cmp(&b.quality()) + // sort by kind + .then( + Ord::cmp( + a.tags.first().map_or("", |tag| tag.name()), + b.tags.first().map_or("", |tag| tag.name()), + ) + ) + // sort by name + .then(Ord::cmp(a.name(), b.name())) + }); +} - // Push new pair if can't merge - if !updated { - let _ = new_item.set_amount(n); - good_map.push(new_item); - } - // Don't forget to cut supply - *supply -= n as f32; - } - } +fn transfer(wares: &mut Vec, bag: &mut Item) { + let capacity = bag.slots().len(); + for (s, w) in bag + .slots_mut() + .iter_mut() + .zip(wares.drain(0..wares.len().min(capacity))) + { + *s = Some(w); } - - good_map } #[derive(Copy, Clone, PartialEq)]