Diversify price calculation for items by using multiple categories per item.

This commit is contained in:
Christof Petig 2022-03-03 02:32:34 +00:00 committed by Justin Shipsey
parent dbc969251c
commit f347b9de11
7 changed files with 648 additions and 614 deletions

View File

@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved site placement - Improved site placement
- [Server] Kick clients who send messages on the wrong stream - [Server] Kick clients who send messages on the wrong stream
- Reworked Merchant trade price calculation, Merchants offer more wares
### Removed ### Removed

View File

@ -47,10 +47,9 @@ loot_tables: [
// this is the amount of that good the most common item represents // 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) // so basically this table balances the goods against each other (higher=less valuable)
good_scaling: [ good_scaling: [
(Potions, 0.005), // common.items.consumable.potion_minor (Food, 0.0375), // common.items.food.mushroom
(Food, 0.15), // common.items.food.mushroom
(Coin, 1.0), // common.items.utility.coins (Coin, 1.0), // common.items.utility.coins
(Armor, 0.5), // common.items.armor.misc.pants.worker_blue (Armor, 0.025), // common.items.armor.misc.pants.worker_blue
(Tools, 0.5), // common.items.weapons.staff.starter_staff (Tools, 0.015487), // common.items.weapons.staff.starter_staff
(Ingredients, 0.5), // common.items.crafting_ing.leather_scraps (Ingredients, 0.034626), // common.items.crafting_ing.leather_scraps
]) ])

View File

@ -1,5 +1,6 @@
use crate::{ use crate::{
assets::{self, AssetExt}, assets::{self, AssetExt},
comp::item::Item,
lottery::LootSpec, lottery::LootSpec,
recipe::{default_recipe_book, RecipeInput}, recipe::{default_recipe_book, RecipeInput},
trade::Good, trade::Good,
@ -9,66 +10,208 @@ use hashbrown::HashMap;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use serde::Deserialize; use serde::Deserialize;
use std::cmp::Ordering; use std::cmp::Ordering;
use tracing::{info, warn}; use tracing::{error, info, warn};
const PRICING_DEBUG: bool = false; const PRICING_DEBUG: bool = false;
#[derive(Default, Debug)] #[derive(Default, Debug)]
pub struct TradePricing { pub struct TradePricing {
// items of different good kinds items: PriceEntries,
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<String, (Good, f32)>,
equality_set: EqualitySet, equality_set: EqualitySet,
} }
// item asset specifier, probability, whether it's sellable by merchants // combination logic:
type Entry = (String, f32, bool); // 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)] /// Material equivalent for an item (price)
struct Entries { #[derive(Default, Debug, Clone)]
entries: Vec<Entry>, pub struct MaterialUse(Vec<(f32, Good)>);
impl std::ops::Mul<f32> 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 { // used by the add variants
fn add(&mut self, eqset: &EqualitySet, item_name: &str, probability: f32, can_sell: bool) { fn vector_add_eq(result: &mut Vec<(f32, Good)>, rhs: &[(f32, Good)]) {
let canonical_itemname = eqset.canonical(item_name); for (amount, good) in rhs {
if result
let old = self
.entries
.iter_mut() .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 impl std::ops::Add for MaterialUse {
if let Some((asset, ref mut old_probability, _)) = old { type Output = Self;
if PRICING_DEBUG {
info!("Update {} {}+{}", asset, old_probability, probability); fn add(self, rhs: Self) -> Self::Output {
let mut result = self;
vector_add_eq(&mut result.0, &rhs.0);
result
} }
*old_probability += probability; }
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<MaterialUse> for MaterialFrequency {
fn from(u: MaterialUse) -> Self {
let mut result = Self(u.0);
vector_invert(&mut result.0);
result
}
}
// identical computation
impl From<MaterialFrequency> 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<PriceEntry>);
#[derive(Default, Debug)]
struct FreqEntries(Vec<FreqEntry>);
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 { } else {
if PRICING_DEBUG { self.0.push(b);
info!("New {} {}", item_name, probability);
} }
self.entries }
.push((canonical_itemname.to_owned(), probability, can_sell)); }
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!("Update {} {:?}+{:?}", asset, old_probability, probability);
}
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 // 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, // It will have infinity as its price, but it's fine,
// because we determine all prices based on canonical value // because we determine all prices based on canonical value
if canonical_itemname != item_name if canonical_itemname != item_name && !self.0.iter().any(|elem| elem.name == *item_name) {
&& !self.entries.iter().any(|(name, _, _)| name == item_name) self.0.push(FreqEntry {
{ name: item_name.to_owned(),
self.entries.push((item_name.to_owned(), 0.0, can_sell)); freq: Default::default(),
sell: can_sell,
stackable: false,
});
} }
} }
} }
@ -197,27 +340,10 @@ impl assets::Compound for EqualitySet {
struct RememberedRecipe { struct RememberedRecipe {
output: String, output: String,
amount: u32, amount: u32,
material_cost: f32, material_cost: Option<f32>,
input: Vec<(String, u32)>, 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<TradingPriceFile>, good: Good) -> f32 { fn get_scaling(contents: &AssetGuard<TradingPriceFile>, good: Good) -> f32 {
contents contents
.good_scaling .good_scaling
@ -231,135 +357,109 @@ impl TradePricing {
const CRAFTING_FACTOR: f32 = 0.95; const CRAFTING_FACTOR: f32 = 0.95;
// increase price a bit compared to sum of ingredients // increase price a bit compared to sum of ingredients
const INVEST_FACTOR: f32 = 0.33; 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 good_from_item(name: &str) -> Good {
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] {
match name { match name {
// Armor _ if name.starts_with("common.items.armor.") => Good::Armor,
_ if name.starts_with("common.items.armor.") => &self.armor.entries,
// Tools _ if name.starts_with("common.items.weapons.") => Good::Tools,
_ if name.starts_with("common.items.weapons.") => &self.tools.entries, _ if name.starts_with("common.items.tool.") => Good::Tools,
_ if name.starts_with("common.items.tool.") => &self.tools.entries,
// Ingredients _ if name.starts_with("common.items.crafting_ing.") => Good::Ingredients,
_ if name.starts_with("common.items.crafting_ing.") => &self.ingredients.entries, _ if name.starts_with("common.items.mineral.") => Good::Ingredients,
_ if name.starts_with("common.items.mineral.") => &self.ingredients.entries, _ if name.starts_with("common.items.flowers.") => Good::Ingredients,
_ if name.starts_with("common.items.flowers.") => &self.ingredients.entries,
// Potions _ if name.starts_with("common.items.consumable.") => Good::Potions,
_ if name.starts_with("common.items.consumable.") => &self.potions.entries,
// Food _ if name.starts_with("common.items.food.") => Good::Food,
_ if name.starts_with("common.items.food.") => &self.food.entries,
// Other Self::COIN_ITEM => Good::Coin,
_ 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.glider.") => Good::default(),
_ if name.starts_with("common.items.boss_drops.") => &self.other.entries, _ if name.starts_with("common.items.utility.") => Good::default(),
_ if name.starts_with("common.items.crafting_tools.") => &self.other.entries, _ if name.starts_with("common.items.boss_drops.") => Good::default(),
_ if name.starts_with("common.items.lantern.") => &self.other.entries, _ if name.starts_with("common.items.crafting_tools.") => Good::default(),
_ if name.starts_with("common.items.lantern.") => Good::default(),
_ => { _ => {
warn!("unknown loot item {}", name); warn!("unknown loot item {}", name);
&self.other.entries Good::default()
},
}
}
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
}, },
} }
} }
// look up price (inverse frequency) of an item // look up price (inverse frequency) of an item
fn price_lookup(&self, eqset: &EqualitySet, requested_name: &str) -> f32 { fn price_lookup(&self, requested_name: &str) -> Option<&MaterialUse> {
let canonical_name = eqset.canonical(requested_name); let canonical_name = self.equality_set.canonical(requested_name);
self.items
let goods = self.get_list_by_path(canonical_name); .0
// even if we multiply by INVEST_FACTOR we need to remain
// above UNAVAILABLE_PRICE (add 1.0 to compensate rounding errors)
goods
.iter() .iter()
.find(|(name, _, _)| name == canonical_name) .find(|e| e.name == canonical_name)
.map_or( .map(|e| &e.price)
Self::UNAVAILABLE_PRICE / Self::INVEST_FACTOR + 1.0,
|(_, freq, _)| 1.0 / freq,
)
} }
#[allow(clippy::cast_precision_loss)] fn calculate_material_cost(&self, r: &RememberedRecipe) -> Option<MaterialUse> {
fn calculate_material_cost(&self, r: &RememberedRecipe, eqset: &EqualitySet) -> f32 {
r.input r.input
.iter() .iter()
.map(|(name, amount)| { .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<f32> {
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 // re-look up prices and sort the vector by ascending material cost, return
// whether first cost is finite // whether first cost is finite
fn sort_by_price(&self, recipes: &mut Vec<RememberedRecipe>, eqset: &EqualitySet) -> bool { fn sort_by_price(&self, recipes: &mut Vec<RememberedRecipe>) -> bool {
for recipe in recipes.iter_mut() { 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()); // put None to the end
//info!(?recipes); 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 recipes
.first() .first()
.filter(|recipe| recipe.material_cost < Self::UNAVAILABLE_PRICE) .filter(|recipe| recipe.material_cost.is_some())
.is_some() .is_some()
} }
#[allow(clippy::cast_precision_loss)] // #[allow(clippy::cast_precision_loss)]
fn read() -> Self { fn read() -> Self {
let mut result = Self::default(); let mut result = Self::default();
let mut freq = FreqEntries::default();
let price_config = let price_config =
TradingPriceFile::load_expect("common.trading.item_price_calculation").read(); TradingPriceFile::load_expect("common.trading.item_price_calculation").read();
let eqset = EqualitySet::load_expect("common.trading.item_price_equality").read(); result.equality_set = EqualitySet::load_expect("common.trading.item_price_equality")
result.equality_set = eqset.clone(); .read()
.clone();
for table in &price_config.loot_tables { for table in &price_config.loot_tables {
if PRICING_DEBUG { if PRICING_DEBUG {
info!(?table); info!(?table);
@ -367,14 +467,55 @@ impl TradePricing {
let (frequency, can_sell, asset_path) = table; let (frequency, can_sell, asset_path) = table;
let loot = ProbabilityFile::load_expect(asset_path); let loot = ProbabilityFile::load_expect(asset_path);
for (p, item_asset, amount) in &loot.read().content { for (p, item_asset, amount) in &loot.read().content {
result.get_list_by_path_mut(item_asset).add( let good = Self::good_from_item(item_asset);
&eqset, let scaling = get_scaling(&price_config, good);
freq.add(
&result.equality_set,
item_asset, item_asset,
frequency * p * *amount, good,
frequency * p * *amount * scaling,
*can_sell, *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 // Apply recipe book
let book = default_recipe_book().read(); let book = default_recipe_book().read();
@ -384,7 +525,7 @@ impl TradePricing {
ordered_recipes.push(RememberedRecipe { ordered_recipes.push(RememberedRecipe {
output: asset_path.id().into(), output: asset_path.id().into(),
amount, amount,
material_cost: Self::UNAVAILABLE_PRICE, material_cost: None,
input: recipe input: recipe
.inputs .inputs
.iter() .iter()
@ -406,106 +547,130 @@ impl TradePricing {
// re-evaluate prices based on crafting tables // re-evaluate prices based on crafting tables
// (start with cheap ones to avoid changing material prices after evaluation) // (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| { 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 false
} else if recipe.material_cost < Self::UNAVAILABLE_PRICE { } else if recipe.material_cost.is_some() {
let actual_cost = result.calculate_material_cost(recipe, &eqset); let actual_cost = result.calculate_material_cost(recipe);
if let Some(usage) = actual_cost {
let output_tradeable = recipe.input.iter().all(|(input, _)| { let output_tradeable = recipe.input.iter().all(|(input, _)| {
result result
.get_list_by_path(input) .items
.0
.iter() .iter()
.find(|(item, _, _)| item == input) .find(|item| item.name == *input)
.map_or(false, |(_, _, tradeable)| *tradeable) .map_or(false, |item| item.sell)
}); });
result.get_list_by_path_mut(&recipe.output).add( let item = Item::new_from_asset_expect(&recipe.output);
&eqset, let stackable = item.is_stackable();
&recipe.output, result.items.add_alternative(PriceEntry {
(recipe.amount as f32) / actual_cost * Self::CRAFTING_FACTOR, name: recipe.output.clone(),
output_tradeable, price: usage * (recipe.amount as f32) * Self::CRAFTING_FACTOR,
); sell: output_tradeable,
stackable,
});
} else {
error!("Recipe {:?} incomplete confusion", recipe);
}
false false
} else { } else {
// handle incomplete recipes later
true true
} }
}); });
//info!(?ordered_recipes); //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::<Vec<_>>();
result.material_cache.extend(materials.drain(..));
}
result.coin_scale = get_scaling(&price_config, Good::Coin);
result result
} }
#[allow( // TODO: optimize repeated use
clippy::cast_possible_truncation, fn random_items_impl(
clippy::cast_sign_loss, &self,
clippy::cast_precision_loss stockmap: &mut HashMap<Good, f32>,
)] mut number: u32,
fn random_item_impl(&self, good: Good, amount: f32, selling: bool) -> Option<String> { selling: bool,
if good == Good::Coin { always_coin: bool,
Some(Self::COIN_ITEM.into()) limit: u32,
} else { ) -> Vec<(String, u32)> {
let table = self.get_list(good); let mut candidates: Vec<&PriceEntry> = self
if table.is_empty() .items
|| (selling && table.iter().filter(|(_, _, can_sell)| *can_sell).count() == 0) .0
{
warn!("Good: {:?}, was unreachable.", good);
return None;
}
let upper = table.len();
let lower = table
.iter() .iter()
.enumerate() .filter(|i| {
.find(|i| i.1.1 * amount >= 1.0) let excess = i
.map_or(upper - 1, |i| i.0); .price
loop { .iter()
let index = .find(|j| j.0 >= stockmap.get(&j.1).cloned().unwrap_or_default());
(rand::random::<f32>() * ((upper - lower) as f32)).floor() as usize + lower; excess.is_none()
if table.get(index).map_or(false, |i| !selling || i.2) { && (!selling || i.sell)
break table.get(index).map(|i| i.0.clone()); && (!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::<f32>() * candidates.len() as f32).floor() as usize;
let result2 = candidates[index];
#[must_use] let amount: u32 = if result2.stackable {
pub fn random_item(good: Good, amount: f32, selling: bool) -> Option<String> { let max_amount = result2
TRADE_PRICING.random_item_impl(good, amount, selling) .price
} .iter()
.map(|e| {
#[must_use] stockmap
pub fn get_material(item: &str) -> (Good, f32) { .get_mut(&e.1)
if item == Self::COIN_ITEM { .map(|stock| *stock / e.0.max(0.001))
(Good::Coin, 1.0) .unwrap_or_default()
})
.fold(f32::INFINITY, f32::min)
.min(limit as f32);
(rand::random::<f32>() * (max_amount - 1.0)).floor() as u32 + 1
} else { } else {
let item = TRADE_PRICING.equality_set.canonical(item); 1
};
TRADE_PRICING.material_cache.get(item).copied().map_or( for i in result2.price.iter() {
(Good::Terrain(crate::terrain::BiomeKind::Void), 0.0), stockmap.get_mut(&i.1).map(|v| *v -= i.0 * (amount as f32));
|(a, b)| (a, b * TRADE_PRICING.coin_scale),
)
} }
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<Good, f32>,
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 get_materials(item: &str) -> Option<&MaterialUse> {
TRADE_PRICING.get_materials_impl(item)
} }
#[cfg(test)] #[cfg(test)]
@ -513,69 +678,32 @@ impl TradePricing {
#[cfg(test)] #[cfg(test)]
fn print_sorted(&self) { fn print_sorted(&self) {
use crate::comp::item::{armor, tool, Item, ItemKind}; use crate::comp::item::{armor, tool, ItemKind};
// we pass the item and the inverse of the price to the closure
fn printvec<F>(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,
);
}
}
println!("Item, ForSale, Amount, Good, Quality, Deal, Unit,"); println!("Item, ForSale, Amount, Good, Quality, Deal, Unit,");
printvec( fn more_information(i: &Item, p: f32) -> (String, &'static str) {
"Armor",
&self.armor.entries,
|i, p| {
if let ItemKind::Armor(a) = &i.kind { if let ItemKind::Armor(a) = &i.kind {
(
match a.protection() { match a.protection() {
Some(armor::Protection::Invincible) => "Invincible".into(), Some(armor::Protection::Invincible) => "Invincible".into(),
Some(armor::Protection::Normal(x)) => format!("{:.4}", x * p), Some(armor::Protection::Normal(x)) => format!("{:.4}", x * p),
None => "0.0".into(), None => "0.0".into(),
}
} else {
format!("{:?}", i.kind)
}
}, },
"prot/val", "prot/val",
); )
printvec( } else if let ItemKind::Tool(t) = &i.kind {
"Tools", (
&self.tools.entries,
|i, p| {
if let ItemKind::Tool(t) = &i.kind {
match &t.stats { match &t.stats {
tool::StatKind::Direct(d) => { tool::StatKind::Direct(d) => {
format!("{:.4}", d.power * d.speed * p) format!("{:.4}", d.power * d.speed * p)
}, },
tool::StatKind::Modular => "Modular".into(), tool::StatKind::Modular => "Modular".into(),
}
} else {
format!("{:?}", i.kind)
}
}, },
"dps/val", "dps/val",
); )
printvec( } else if let ItemKind::Consumable { kind: _, effects } = &i.kind {
"Potions", (
&self.potions.entries,
|i, p| {
if let ItemKind::Consumable { kind: _, effects } = &i.kind {
effects effects
.iter() .iter()
.map(|e| { .map(|e| {
@ -586,43 +714,53 @@ impl TradePricing {
} }
}) })
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(" ") .join(" "),
} else {
format!("{:?}", i.kind)
}
},
"str/val", "str/val",
); )
printvec( } else {
"Food", (Default::default(), "")
&self.food.entries, }
|i, p| { }
if let ItemKind::Consumable { kind: _, effects } = &i.kind { let mut sorted: Vec<(f32, &PriceEntry)> = self
effects .items
.0
.iter() .iter()
.map(|e| { .map(|e| (e.price.iter().map(|i| i.0.to_owned()).sum(), e))
if let crate::effect::Effect::Buff(b) = e { .collect();
format!("{:.2}", b.data.strength * p) sorted.sort_by(|(p, e), (p2, e2)| {
} else { p2.partial_cmp(p)
format!("{:?}", e) .unwrap_or(Ordering::Equal)
} .then(e.name.cmp(&e2.name))
}) });
.collect::<Vec<String>>()
.join(" ") for (
} else { pricesum,
format!("{:?}", i.kind) PriceEntry {
} name: item_id,
price: mat_use,
sell: can_sell,
stackable: _,
}, },
"str/val", ) in sorted.iter()
{
let it = Item::new_from_asset_expect(item_id);
//let price = mat_use.iter().map(|(amount, _good)| *amount).sum::<f32>();
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,
); );
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);
} }
} }
@ -684,10 +822,19 @@ mod tests {
init(); init();
info!("init"); info!("init");
for _ in 0..5 { let mut stock: hashbrown::HashMap<Good, f32> = vec![
if let Some(item_id) = TradePricing::random_item(Good::Armor, 5.0, false) { (Good::Ingredients, 50.0),
info!("Armor 5 {}", item_id); (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);
} }
} }

View File

@ -367,11 +367,18 @@ impl SitePrices {
.as_ref() .as_ref()
.and_then(|ri| { .and_then(|ri| {
ri.inventory.get(slot).map(|item| { ri.inventory.get(slot).map(|item| {
let (material, factor) = TradePricing::get_material(&item.name); if let Some(vec) = TradePricing::get_materials(&item.name) {
self.values.get(&material).cloned().unwrap_or_default() vec.iter()
* factor .map(|(amount2, material)| {
self.values.get(material).copied().unwrap_or_default()
* *amount2
* (if reduce { material.trade_margin() } else { 1.0 })
})
.sum::<f32>()
* (*amount as f32) * (*amount as f32)
* if reduce { material.trade_margin() } else { 1.0 } } else {
0.0
}
}) })
}) })
.unwrap_or_default() .unwrap_or_default()

View File

@ -3730,17 +3730,26 @@ impl Hud {
false, false,
); );
if let Some(item) = inventory.get(slot) { if let Some(item) = inventory.get(slot) {
let (material, factor) = if let Some(materials) =
TradePricing::get_material(item.item_definition_id()); TradePricing::get_materials(item.item_definition_id())
let mut unit_price = prices {
let unit_price: f32 = materials
.iter()
.map(|e| {
prices
.values .values
.get(&material) .get(&e.1)
.cloned() .cloned()
.unwrap_or_default() .unwrap_or_default()
* factor; * e.0
if ours { * (if ours {
unit_price *= material.trade_margin(); e.1.trade_margin()
} } else {
1.0
})
})
.sum();
let mut float_delta = if ours ^ remove { let mut float_delta = if ours ^ remove {
(balance1 - balance0) / unit_price (balance1 - balance0) / unit_price
} else { } else {
@ -3754,6 +3763,7 @@ impl Hud {
*quantity = float_delta.max(0.0) as u32; *quantity = float_delta.max(0.0) as u32;
} }
} }
}
}; };
match slot { match slot {
Inventory(i) => { Inventory(i) => {

View File

@ -22,13 +22,25 @@ pub fn price_desc(
i18n: &Localization, i18n: &Localization,
) -> Option<(String, String, f32)> { ) -> Option<(String, String, f32)> {
if let Some(prices) = prices { if let Some(prices) = prices {
let (material, factor) = TradePricing::get_material(item_definition_id); if let Some(materials) = TradePricing::get_materials(item_definition_id) {
let coinprice = prices.values.get(&Good::Coin).cloned().unwrap_or(1.0); let coinprice = prices.values.get(&Good::Coin).cloned().unwrap_or(1.0);
let buyprice = prices.values.get(&material).cloned().unwrap_or_default() * factor; let buyprice: f32 = materials
let sellprice = buyprice * material.trade_margin(); .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) let deal_goodness: f32 = materials
/ prices.values.get(&Good::Coin).cloned().unwrap_or(1.0); .iter()
.map(|e| prices.values.get(&e.1).cloned().unwrap_or(0.0))
.sum::<f32>()
/ prices.values.get(&Good::Coin).cloned().unwrap_or(1.0)
/ (materials.len() as f32);
let deal_goodness = deal_goodness.log(2.0); let deal_goodness = deal_goodness.log(2.0);
let buy_string = format!( let buy_string = format!(
"{} : {:0.1} {}", "{} : {:0.1} {}",
@ -53,6 +65,9 @@ pub fn price_desc(
} else { } else {
None None
} }
} else {
None
}
} }
pub fn kind_text<'a>(kind: &ItemKind, i18n: &'a Localization) -> Cow<'a, str> { pub fn kind_text<'a>(kind: &ItemKind, i18n: &'a Localization) -> Cow<'a, str> {

View File

@ -35,12 +35,7 @@ use fxhash::FxHasher64;
use hashbrown::{HashMap, HashSet}; use hashbrown::{HashMap, HashSet};
use rand::prelude::*; use rand::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use std::{ use std::{collections::VecDeque, f32, hash::BuildHasherDefault};
cmp::{self, min},
collections::VecDeque,
f32,
hash::BuildHasherDefault,
};
use vek::*; use vek::*;
#[derive(Deserialize)] #[derive(Deserialize)]
@ -1019,236 +1014,96 @@ pub fn merchant_loadout(
) -> LoadoutBuilder { ) -> LoadoutBuilder {
let rng = &mut rand::thread_rng(); let rng = &mut rand::thread_rng();
// Fill backpack with ingredients and coins let mut backpack = Item::new_from_asset_expect("common.items.armor.misc.back.backpack");
let backpack = ingredient_backpack(economy, rng); 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");
// Fill bags with stuff let mut bag3 = Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack");
let (food_bag, potion_bag) = consumable_bags(economy, rng); let mut bag4 = Item::new_from_asset_expect("common.items.armor.misc.bag.sturdy_red_backpack");
let weapon_bag = weapon_bag(economy); let slots = backpack.slots().len() + 4 * bag1.slots().len();
let armor_bag = armor_bag(economy); let mut stockmap: HashMap<Good, f32> = economy
.map(|e| e.unconsumed_stock.clone())
loadout_builder .unwrap_or_default();
.with_asset_expect("common.loadout.village.merchant", rng) // modify stock for better gameplay
.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"),
);
// TODO: currently econsim spends all its food on population, resulting in none // 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 // 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 // food for sale at every site, to be used until we have some solution like NPC
// houses as a limit on econsim population growth // houses as a limit on econsim population growth
let mut food = economy stockmap
.and_then(|e| e.unconsumed_stock.get(&Good::Food)) .entry(Good::Food)
.copied() .and_modify(|e| *e = e.max(10_000.0))
.map_or(Some(10_000.0), |food| Some(food.max(10_000.0))); .or_insert(10_000.0);
// Reduce amount of potions so merchants do not oversupply potions. // Reduce amount of potions so merchants do not oversupply potions.
// TODO: Maybe remove when merchants and their inventories are rtsim? // TODO: Maybe remove when merchants and their inventories are rtsim?
let mut potions = economy // Note: Likely without effect now that potions are counted as food
.and_then(|e| e.unconsumed_stock.get(&Good::Potions)) stockmap
.copied() .entry(Good::Potions)
.map(|potions| potions.powf(0.25)); .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<Item> =
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 = [ loadout_builder
(Good::Food, &mut food, &mut bag3), .with_asset_expect("common.loadout.village.merchant", rng)
(Good::Potions, &mut potions, &mut bag4), .back(Some(backpack))
]; .bag(ArmorSlot::Bag1, Some(bag1))
.bag(ArmorSlot::Bag2, Some(bag2))
for (good_kind, goods, bag) in goods { .bag(ArmorSlot::Bag3, Some(bag3))
// Try to get goods as many times as we have slots .bag(ArmorSlot::Bag4, Some(bag4))
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)
} }
/// Returns vector of `tries` to gather given `good_kind` with given `supply` fn sort_wares(bag: &mut Vec<Item>) {
/// Merges items if possible use common::comp::item::TagExampleInfo;
#[warn(clippy::pedantic)]
fn gather_merged_goods(
good_kind: Good,
supply: &mut f32,
tries: usize,
rng: &mut impl Rng,
) -> Vec<Item> {
let mut good_map: Vec<Item> = 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
};
// Try to merge with item we already have bag.sort_by(|a, b| {
let old_item = good_map.iter_mut().find(|old_item| { a.quality()
old_item.item_definition_id() == new_item.item_definition_id() .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()))
}); });
let mut updated = false; }
if let Some(item) = old_item {
if item.set_amount(item.amount() + n).is_ok() {
updated = true;
}
}
// Push new pair if can't merge fn transfer(wares: &mut Vec<Item>, bag: &mut Item) {
if !updated { let capacity = bag.slots().len();
let _ = new_item.set_amount(n); for (s, w) in bag
good_map.push(new_item); .slots_mut()
.iter_mut()
.zip(wares.drain(0..wares.len().min(capacity)))
{
*s = Some(w);
} }
// Don't forget to cut supply
*supply -= n as f32;
}
}
}
good_map
} }
#[derive(Copy, Clone, PartialEq)] #[derive(Copy, Clone, PartialEq)]