diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f5897d5b..b369b19bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reset button for graphics settings - Gave weapons critical strike {chance, multiplier} stats - A system to add glow and reflection effects to figures (i.e: characters, armour, weapons, etc.) +- Merchants will trade wares with players ### Changed diff --git a/Cargo.lock b/Cargo.lock index 58d713077a..3e6de98ee3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5586,6 +5586,7 @@ dependencies = [ "spin_sleep", "structopt", "tracing", + "tracing-subscriber", "uuid", "vek 0.14.1", "veloren-common-base", @@ -5873,6 +5874,7 @@ name = "veloren-world" version = "0.8.0" dependencies = [ "arr_macro", + "assets_manager", "bincode", "bitvec", "criterion", diff --git a/assets/common/item_price_calculation.ron b/assets/common/item_price_calculation.ron new file mode 100644 index 0000000000..3108f680ab --- /dev/null +++ b/assets/common/item_price_calculation.ron @@ -0,0 +1,44 @@ +( +loot_tables: [ + // balance the loot tables against each other (higher= more common= smaller price) + // the fact that loot tables have an own probability not accessible outside of the lottery call doesn't help here + (0.5,"common.loot_tables.loot_table_animal_ice"), + (4,"common.loot_tables.loot_table_animal_parts"), + (1,"common.loot_tables.loot_table_armor_cloth"), + (0.01,"common.loot_tables.loot_table_armor_heavy"), + (0.1,"common.loot_tables.loot_table_armor_light"), + (0.1,"common.loot_tables.loot_table_armor_misc"), + (0.5,"common.loot_tables.loot_table_armor_nature"), + (0.1,"common.loot_tables.loot_table_cave_large"), + (0.1,"common.loot_tables.loot_table_consumables"), + (1,"common.loot_tables.loot_table_crafting"), + (0.005,"common.loot_tables.loot_table_cultists"), + (1,"common.loot_tables.loot_table_fish"), + (1,"common.loot_tables.loot_table_food"), + (1,"common.loot_tables.loot_table_humanoids"), + (1,"common.loot_tables.loot_table_maneater"), + (0.0001,"common.loot_tables.loot_table_mindflayer"), + (0.001,"common.loot_tables.loot_table_miniboss"), + (0.05,"common.loot_tables.loot_table_raptor"), + (1,"common.loot_tables.loot_table_rocks"), + (1,"common.loot_tables.loot_table"), + (0.04,"common.loot_tables.loot_table_saurok"), + (0.02,"common.loot_tables.loot_table_troll"), + (2,"common.loot_tables.loot_table_villager"), + (1,"common.loot_tables.loot_table_weapon_common"), + (0.008,"common.loot_tables.loot_table_weapon_rare"), + (0.01,"common.loot_tables.loot_table_weapon_uncommon"), + (0.01,"common.loot_tables.loot_table_wendigo"), + // we probably want to include all the scattered scatter information + //(0.5,"common.cave_scatter"), +], +// 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.5), // common.items.consumable.potion_minor + (Food, 3.0), // common.items.food.mushroom + (Coin, 1.0), // common.items.utility.coins + (Armor, 0.3), // common.items.armor.misc.pants.worker_blue + (Tools, 1.0), // common.items.weapons.staff.starter_staff + (Ingredients, 5.0), // common.items.crafting_ing.leather_scraps +]) diff --git a/assets/common/loot_tables/loot_table_villager.ron b/assets/common/loot_tables/loot_table_villager.ron index 7ed913701c..341a7f26d8 100644 --- a/assets/common/loot_tables/loot_table_villager.ron +++ b/assets/common/loot_tables/loot_table_villager.ron @@ -17,7 +17,7 @@ (1, "common.items.armor.misc.chest.worker_red_1"), (1, "common.items.armor.misc.chest.worker_yellow_0"), (1, "common.items.armor.misc.chest.worker_yellow_1"), - (1, "common.items.armor.misc.pants.worker_blue_0"), + (1, "common.items.armor.misc.pants.worker_blue"), // Utility (0.05, "common.items.utility.collar"), // Food diff --git a/assets/common/professions.ron b/assets/common/professions.ron new file mode 100644 index 0000000000..6dfe5a6edf --- /dev/null +++ b/assets/common/professions.ron @@ -0,0 +1,110 @@ +// we use a vector to easily generate a key into all the economic data containers +([ + ( + name: "Cook", + orders: [ (Flour, 12.0), (Meat, 4.0), (Wood, 1.5), (Stone, 1.0) ], + products: [ (Food, 16.0) ], + ), + ( + name: "Lumberjack", + orders: [ (Territory(Forest), 0.5), (Tools, 0.1) ], + products: [ (Wood, 0.5)], + ), + ( + name: "Miner", + orders: [ (Territory(Mountain), 0.5), (Tools, 0.1) ], + products: [ (Stone, 0.5) ], + ), + ( + name: "Fisher", + orders: [ (Territory(Lake), 4.0), (Tools, 0.02) ], + products: [ (Meat, 4.0) ], + ), + ( + name: "Hunter", // Hunter operate outside of uncontrolled areas and resemble guards + // due to the low number of products we tune down the Armor,Tools,Potions in comparison + orders: [ (Armor, 0.1), (Tools, 0.1), (Potions, 1.0), (Terrain(Forest), 4.0) ], + products: [ (Meat, 4.0) ], + ), + ( + name: "Hunter2", // Hunter operate outside of uncontrolled areas and resemble guards + // due to the low number of products we tune down the Armor,Tools,Potions in comparison + orders: [ (Armor, 0.1), (Tools, 0.1), (Potions, 1.0), (Terrain(Desert), 5.0) ], + products: [ (Meat, 3.0) ], + ), + ( + name: "Farmer", + orders: [ (Territory(Grassland), 2.0), (Tools, 0.05) ], + products: [ (Flour, 2.0) ], + ), + ( + name: "Brewer", + orders: [ (Ingredients, 2.0), (Flour, 2.0) ], + products: [ (Potions, 6.0) ], + ), + ( + name: "Bladesmith", + orders: [ (Ingredients, 4.0), (Wood, 1.0) ], + products: [ (Tools, 2.0) ], + ), + ( + name: "Blacksmith", + orders: [ (Ingredients, 8.0), (Wood, 2.0) ], + products: [ (Armor, 4.0) ], + ), + ( + name: "Naval Guard", + orders: [ (Armor, 0.3), (Tools, 0.3), (Potions, 4.0), (Terrain(Lake), 50) ], + products: [ (Territory(Lake), 50) ], + ), + ( + name: "Mountain Guard", + orders: [ (Armor, 0.4), (Tools, 0.4), (Potions, 3.5), (Terrain(Mountain), 50) ], + products: [ (Territory(Mountain), 50) ], + ), + ( + name: "Field Guard", + orders: [ (Armor, 0.5), (Tools, 0.3), (Potions, 3.0), (Terrain(Grassland), 50) ], + products: [ (Territory(Grassland), 50) ], + ), + ( + name: "Road Patrol", + orders: [ (Armor, 0.5), (Tools, 0.3), (Potions, 3.0), ], + products: [ (RoadSecurity, 50) ], + ), + ( + name: "Ranger", + orders: [ (Armor, 0.5), (Tools, 0.3), (Potions, 3.0), (Terrain(Forest), 50) ], + products: [ (Territory(Forest), 50) ], + ), + ( + name: "Armed Gatherer", // similar to guards + orders: [ (Armor, 0.5), (Tools, 0.3), (Potions, 3.0), (Terrain(Desert), 10) ], + products: [ (Ingredients, 10) ], + ), + ( + name: "Gatherer", // operates on controlled area + orders: [ (Territory(Grassland), 0.1) ], + products: [ (Ingredients, 4) ], + ), + ( + name: "Gatherer2", // operates on controlled area + orders: [ (Territory(Forest), 0.1) ], + products: [ (Ingredients, 4) ], + ), + ( + name: "Gatherer3", // operates on controlled area + orders: [ (Territory(Mountain), 0.3) ], + products: [ (Ingredients, 4) ], + ), + ( + name: "Merchant", + orders: [ (RoadSecurity, 0.5) ], + products: [ (Transportation, 30.0) ], + ), + ( + name: "_", + orders: [ (Food, 0.5) ], + products: [], + ), +]) diff --git a/client/src/lib.rs b/client/src/lib.rs index 4a1e6ca740..5377600805 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -41,10 +41,12 @@ use common::{ use common_base::span; use common_net::{ msg::{ - self, validate_chat_msg, world_msg::SiteInfo, ChatMsgValidationError, ClientGeneral, - ClientMsg, ClientRegister, ClientType, DisconnectReason, InviteAnswer, Notification, - PingMsg, PlayerInfo, PlayerListUpdate, PresenceKind, RegisterError, ServerGeneral, - ServerInfo, ServerInit, ServerRegisterAnswer, MAX_BYTES_CHAT_MSG, + self, validate_chat_msg, + world_msg::{EconomyInfo, SiteId, SiteInfo}, + ChatMsgValidationError, ClientGeneral, ClientMsg, ClientRegister, ClientType, + DisconnectReason, InviteAnswer, Notification, PingMsg, PlayerInfo, PlayerListUpdate, + PresenceKind, RegisterError, ServerGeneral, ServerInfo, ServerInit, ServerRegisterAnswer, + MAX_BYTES_CHAT_MSG, }, sync::WorldSyncExt, }; @@ -126,6 +128,11 @@ impl WorldData { pub fn max_chunk_alt(&self) -> f32 { self.map.2.y } } +pub struct SiteInfoRich { + pub site: SiteInfo, + pub economy: Option, +} + pub struct Client { registered: bool, presence: Option, @@ -134,7 +141,7 @@ pub struct Client { world_data: WorldData, player_list: HashMap, character_list: CharacterList, - sites: Vec, + sites: HashMap, pub chat_mode: ChatMode, recipe_book: RecipeBook, available_recipes: HashSet, @@ -440,7 +447,15 @@ impl Client { }, player_list: HashMap::new(), character_list: CharacterList::default(), - sites, + sites: sites + .iter() + .map(|s| { + (s.id, SiteInfoRich { + site: s.clone(), + economy: None, + }) + }) + .collect(), recipe_book, available_recipes: HashSet::default(), chat_mode: ChatMode::default(), @@ -575,6 +590,7 @@ impl Client { | ClientGeneral::PlayerPhysics { .. } | ClientGeneral::UnlockSkill(_) | ClientGeneral::RefundSkill(_) + | ClientGeneral::RequestSiteInfo(_) | ClientGeneral::UnlockSkillGroup(_) => &mut self.in_game_stream, //Only in game, terrain ClientGeneral::TerrainChunkRequest { .. } => &mut self.terrain_stream, @@ -777,7 +793,9 @@ impl Client { } /// Unstable, likely to be removed in a future release - pub fn sites(&self) -> &[SiteInfo] { &self.sites } + pub fn sites(&self) -> &HashMap { &self.sites } + + pub fn sites_mut(&mut self) -> &mut HashMap { &mut self.sites } pub fn enable_lantern(&mut self) { self.send_msg(ClientGeneral::ControlEvent(ControlEvent::EnableLantern)); @@ -1038,6 +1056,10 @@ impl Client { } } + pub fn request_site_economy(&mut self, id: SiteId) { + self.send_msg(ClientGeneral::RequestSiteInfo(id)) + } + pub fn inventories(&self) -> ReadStorage { self.state.read_storage() } /// Send a chat message to the server. @@ -1604,6 +1626,11 @@ impl Client { frontend_events.push(Event::TradeComplete { result, trade }) } }, + ServerGeneral::SiteEconomy(economy) => { + if let Some(rich) = self.sites_mut().get_mut(&economy.id) { + rich.economy = Some(economy); + } + }, _ => unreachable!("Not a in_game message"), } Ok(()) diff --git a/common/Cargo.toml b/common/Cargo.toml index 7e6e1a0955..9001b753b1 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -66,6 +66,9 @@ specs-idvs = { git = "https://gitlab.com/veloren/specs-idvs.git", rev = "b65fb22 #bench criterion = "0.3" +#test +tracing-subscriber = { version = "0.2.15", default-features = false, features = ["fmt", "chrono", "ansi", "smallvec", "env-filter"] } + [[bench]] name = "chonk_benchmark" harness = false diff --git a/common/net/src/msg/client.rs b/common/net/src/msg/client.rs index e44514798b..2ebf0c251e 100644 --- a/common/net/src/msg/client.rs +++ b/common/net/src/msg/client.rs @@ -1,4 +1,4 @@ -use super::PingMsg; +use super::{world_msg::SiteId, PingMsg}; use common::{ character::CharacterId, comp, @@ -73,6 +73,7 @@ pub enum ClientGeneral { UnlockSkill(Skill), RefundSkill(Skill), UnlockSkillGroup(SkillGroupKind), + RequestSiteInfo(SiteId), //Only in Game, via terrain stream TerrainChunkRequest { key: Vec2, @@ -115,6 +116,7 @@ impl ClientMsg { | ClientGeneral::TerrainChunkRequest { .. } | ClientGeneral::UnlockSkill(_) | ClientGeneral::RefundSkill(_) + | ClientGeneral::RequestSiteInfo(_) | ClientGeneral::UnlockSkillGroup(_) => { c_type == ClientType::Game && presence.is_some() }, diff --git a/common/net/src/msg/server.rs b/common/net/src/msg/server.rs index 23fbe5372c..d761db5b40 100644 --- a/common/net/src/msg/server.rs +++ b/common/net/src/msg/server.rs @@ -1,4 +1,4 @@ -use super::{ClientType, EcsCompPacket, PingMsg}; +use super::{world_msg::EconomyInfo, ClientType, EcsCompPacket, PingMsg}; use crate::sync; use authc::AuthClientError; use common::{ @@ -127,6 +127,8 @@ pub enum ServerGeneral { Notification(Notification), UpdatePendingTrade(TradeId, PendingTrade), FinishedTrade(TradeResult), + /// Economic information about sites + SiteEconomy(EconomyInfo), } impl ServerGeneral { @@ -235,7 +237,8 @@ impl ServerMsg { | ServerGeneral::Outcomes(_) | ServerGeneral::Knockback(_) | ServerGeneral::UpdatePendingTrade(_, _) - | ServerGeneral::FinishedTrade(_) => { + | ServerGeneral::FinishedTrade(_) + | ServerGeneral::SiteEconomy(_) => { c_type == ClientType::Game && presence.is_some() }, // Always possible diff --git a/common/net/src/msg/world_msg.rs b/common/net/src/msg/world_msg.rs index 4a11f715b4..12b0c46f61 100644 --- a/common/net/src/msg/world_msg.rs +++ b/common/net/src/msg/world_msg.rs @@ -1,5 +1,6 @@ -use common::grid::Grid; +use common::{grid::Grid, trade::Good}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use vek::*; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -124,8 +125,11 @@ pub struct WorldMapMsg { pub sites: Vec, } +pub type SiteId = common::trade::SiteId; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SiteInfo { + pub id: SiteId, pub kind: SiteKind, pub wpos: Vec2, pub name: Option, @@ -140,3 +144,15 @@ pub enum SiteKind { Cave, Tree, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EconomyInfo { + pub id: SiteId, + pub population: u32, + pub stock: HashMap, + pub labor_values: HashMap, + pub values: HashMap, + pub labors: Vec, + pub last_exports: HashMap, + pub resources: HashMap, +} diff --git a/common/src/comp/agent.rs b/common/src/comp/agent.rs index 6209f6815f..67a870b60b 100644 --- a/common/src/comp/agent.rs +++ b/common/src/comp/agent.rs @@ -2,6 +2,7 @@ use crate::{ comp::{humanoid, quadruped_low, quadruped_medium, quadruped_small, Body}, path::Chaser, rtsim::RtSimController, + trade::{PendingTrade, ReducedInventory, SiteId, SitePrices, TradeId, TradeResult}, uid::Uid, }; use specs::{Component, Entity as EcsEntity}; @@ -10,6 +11,7 @@ use std::collections::VecDeque; use vek::*; pub const DEFAULT_INTERACTION_TIME: f32 = 3.0; +pub const TRADE_INTERACTION_TIME: f32 = 300.0; #[derive(Eq, PartialEq)] pub enum Tactic { @@ -173,7 +175,17 @@ impl<'a> From<&'a Body> for Psyche { pub enum AgentEvent { /// Engage in conversation with entity with Uid Talk(Uid), - Trade(Uid), + TradeInvite(Uid), + FinishedTrade(TradeResult), + UpdatePendingTrade( + // this data structure is large so box it to keep AgentEvent small + Box<( + TradeId, + PendingTrade, + SitePrices, + [Option; 2], + )>, + ), // Add others here } @@ -192,6 +204,8 @@ pub struct Agent { /// Does the agent talk when e.g. hit by the player // TODO move speech patterns into a Behavior component pub can_speak: bool, + pub trade_for_site: Option, + pub trading: bool, pub psyche: Psyche, pub inbox: VecDeque, pub action_timer: f32, @@ -207,12 +221,14 @@ impl Agent { pub fn new( patrol_origin: Option>, can_speak: bool, + trade_for_site: Option, body: &Body, no_flee: bool, ) -> Self { Agent { patrol_origin, can_speak, + trade_for_site, psyche: if no_flee { Psyche { aggro: 1.0 } } else { diff --git a/common/src/comp/inventory/item/mod.rs b/common/src/comp/inventory/item/mod.rs index f76b099ec7..f05ae30667 100644 --- a/common/src/comp/inventory/item/mod.rs +++ b/common/src/comp/inventory/item/mod.rs @@ -264,6 +264,9 @@ impl ItemDef { } } + // currently needed by trade_pricing + pub fn id(&self) -> &str { &self.item_definition_id } + #[cfg(test)] pub fn new_test( item_definition_id: String, diff --git a/common/src/comp/inventory/loadout_builder.rs b/common/src/comp/inventory/loadout_builder.rs index 59550ae69f..405d44628c 100644 --- a/common/src/comp/inventory/loadout_builder.rs +++ b/common/src/comp/inventory/loadout_builder.rs @@ -1,11 +1,15 @@ -use crate::comp::{ - biped_large, biped_small, golem, - inventory::{ - loadout::Loadout, - slot::{ArmorSlot, EquipSlot}, +use crate::{ + comp::{ + biped_large, biped_small, golem, + inventory::{ + loadout::Loadout, + slot::{ArmorSlot, EquipSlot}, + trade_pricing::TradePricing, + }, + item::{tool::ToolKind, Item, ItemKind}, + object, quadruped_low, quadruped_medium, theropod, Body, }, - item::{tool::ToolKind, Item, ItemKind}, - object, quadruped_low, quadruped_medium, theropod, Body, + trade::{Good, SiteInformation}, }; use rand::Rng; @@ -39,6 +43,7 @@ pub enum LoadoutConfig { Myrmidon, Guard, Villager, + Merchant, Outcast, Highwayman, Bandit, @@ -78,6 +83,7 @@ impl LoadoutBuilder { body: Body, mut main_tool: Option, config: Option, + economy: Option<&SiteInformation>, ) -> Self { // If no main tool is passed in, checks if species has a default main tool if main_tool.is_none() { @@ -502,6 +508,128 @@ impl LoadoutBuilder { _ => None, }) .build(), + Merchant => { + let mut backpack = + Item::new_from_asset_expect("common.items.armor.misc.back.backpack"); + let mut coins = Item::new_from_asset_expect("common.items.utility.coins"); + coins + .set_amount( + (economy + .map(|e| e.unconsumed_stock.get(&Good::Coin)) + .flatten() + .copied() + .unwrap_or_default() + .round() as u32) + .max(1), + ) + .expect("coins should be stackable"); + backpack.slots_mut()[0] = Some(coins); + let armor = economy + .map(|e| e.unconsumed_stock.get(&Good::Armor)) + .flatten() + .copied() + .unwrap_or_default() + / 10.0; + for i in 1..18 { + backpack.slots_mut()[i] = Some(Item::new_from_asset_expect( + &TradePricing::random_item(Good::Armor, armor), + )); + } + let mut bag1 = Item::new_from_asset_expect( + "common.items.armor.misc.bag.reliable_backpack", + ); + let weapon = economy + .map(|e| e.unconsumed_stock.get(&Good::Tools)) + .flatten() + .copied() + .unwrap_or_default() + / 10.0; + for i in 0..16 { + bag1.slots_mut()[i] = Some(Item::new_from_asset_expect( + &TradePricing::random_item(Good::Tools, weapon), + )); + } + let mut bag2 = Item::new_from_asset_expect( + "common.items.armor.misc.bag.reliable_backpack", + ); + let ingredients = economy + .map(|e| e.unconsumed_stock.get(&Good::Ingredients)) + .flatten() + .copied() + .unwrap_or_default() + / 10.0; + for i in 0..16 { + bag2.slots_mut()[i] = Some(Item::new_from_asset_expect( + &TradePricing::random_item(Good::Ingredients, ingredients), + )); + } + let mut bag3 = Item::new_from_asset_expect( + "common.items.armor.misc.bag.reliable_backpack", + ); + let food = economy + .map(|e| e.unconsumed_stock.get(&Good::Food)) + .flatten() + .copied() + .unwrap_or_default() + / 10.0; + for i in 0..16 { + bag3.slots_mut()[i] = Some(Item::new_from_asset_expect( + &TradePricing::random_item(Good::Food, food), + )); + } + let mut bag4 = Item::new_from_asset_expect( + "common.items.armor.misc.bag.reliable_backpack", + ); + let potions = economy + .map(|e| e.unconsumed_stock.get(&Good::Potions)) + .flatten() + .copied() + .unwrap_or_default() + / 10.0; + for i in 0..16 { + bag4.slots_mut()[i] = Some(Item::new_from_asset_expect( + &TradePricing::random_item(Good::Potions, potions), + )); + } + LoadoutBuilder::new() + .active_item(active_item) + .shoulder(Some(Item::new_from_asset_expect( + "common.items.armor.twigsflowers.shoulder", + ))) + .chest(Some(Item::new_from_asset_expect( + "common.items.armor.twigsflowers.chest", + ))) + .belt(Some(Item::new_from_asset_expect( + "common.items.armor.twigsflowers.belt", + ))) + .hands(Some(Item::new_from_asset_expect( + "common.items.armor.twigsflowers.hand", + ))) + .pants(Some(Item::new_from_asset_expect( + "common.items.armor.twigsflowers.pants", + ))) + .feet(Some(Item::new_from_asset_expect( + "common.items.armor.twigsflowers.foot", + ))) + .lantern(Some(Item::new_from_asset_expect( + "common.items.lantern.black_0", + ))) + .back(Some(backpack)) + .neck(Some(Item::new_from_asset_expect( + "common.items.armor.misc.neck.plain_1", + ))) + .ring1(Some(Item::new_from_asset_expect( + "common.items.armor.misc.ring.gold", + ))) + .ring2(Some(Item::new_from_asset_expect( + "common.items.armor.misc.ring.gold", + ))) + .bag(ArmorSlot::Bag1, Some(bag1)) + .bag(ArmorSlot::Bag2, Some(bag2)) + .bag(ArmorSlot::Bag3, Some(bag3)) + .bag(ArmorSlot::Bag4, Some(bag4)) + .build() + }, Outcast => LoadoutBuilder::new() .active_item(active_item) .shoulder(Some(Item::new_from_asset_expect( @@ -824,5 +952,10 @@ impl LoadoutBuilder { self } + pub fn bag(mut self, which: ArmorSlot, item: Option) -> Self { + self.0.swap(EquipSlot::Armor(which), item); + self + } + pub fn build(self) -> Loadout { self.0 } } diff --git a/common/src/comp/inventory/mod.rs b/common/src/comp/inventory/mod.rs index 666de975d7..105d3e84ee 100644 --- a/common/src/comp/inventory/mod.rs +++ b/common/src/comp/inventory/mod.rs @@ -26,6 +26,7 @@ pub mod loadout_builder; pub mod slot; #[cfg(test)] mod test; #[cfg(test)] mod test_helpers; +pub mod trade_pricing; pub type InvSlot = Option; const DEFAULT_INVENTORY_SLOTS: usize = 18; diff --git a/common/src/comp/inventory/trade_pricing.rs b/common/src/comp/inventory/trade_pricing.rs new file mode 100644 index 0000000000..b18321eb7c --- /dev/null +++ b/common/src/comp/inventory/trade_pricing.rs @@ -0,0 +1,368 @@ +use crate::{ + assets::{self, AssetExt}, + recipe::{default_recipe_book, RecipeInput}, + trade::Good, +}; +use assets_manager::AssetGuard; +use hashbrown::HashMap; +use lazy_static::lazy_static; +use serde::Deserialize; +use tracing::info; + +#[derive(Debug)] +struct Entry { + probability: f32, + item: String, +} + +type Entries = Vec<(String, f32)>; +const PRICING_DEBUG: bool = false; + +#[derive(Default, Debug)] +pub struct TradePricing { + tools: Entries, + armor: Entries, + potions: Entries, + food: Entries, + ingredients: Entries, + other: Entries, + coin_scale: f32, + // rng: ChaChaRng, + + // get amount of material per item + material_cache: HashMap, +} + +lazy_static! { + static ref TRADE_PRICING: TradePricing = TradePricing::read(); +} + +struct ProbabilityFile { + pub content: Vec<(f32, String)>, +} + +impl assets::Asset for ProbabilityFile { + type Loader = assets::LoadFrom, assets::RonLoader>; + + const EXTENSION: &'static str = "ron"; +} + +impl From> for ProbabilityFile { + fn from(content: Vec<(f32, String)>) -> ProbabilityFile { Self { content } } +} + +#[derive(Debug, Deserialize)] +struct TradingPriceFile { + pub loot_tables: Vec<(f32, String)>, + pub good_scaling: Vec<(Good, f32)>, // the amount of Good equivalent to the most common item +} + +impl assets::Asset for TradingPriceFile { + type Loader = assets::LoadFrom; + + const EXTENSION: &'static str = "ron"; +} + +#[derive(Debug)] +struct RememberedRecipe { + output: String, + amount: u32, + material_cost: f32, + input: Vec<(String, u32)>, +} + +impl TradePricing { + const COIN_ITEM: &'static str = "common.items.utility.coins"; + 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 = 1000000.0; + + // add this much of a non-consumed crafting tool price + + fn get_list(&self, good: Good) -> &Entries { + match good { + Good::Armor => &self.armor, + Good::Tools => &self.tools, + Good::Potions => &self.potions, + Good::Food => &self.food, + Good::Ingredients => &self.ingredients, + _ => panic!("invalid good"), + } + } + + fn get_list_mut(&mut self, good: Good) -> &mut Entries { + match good { + Good::Armor => &mut self.armor, + Good::Tools => &mut self.tools, + Good::Potions => &mut self.potions, + Good::Food => &mut self.food, + Good::Ingredients => &mut self.ingredients, + _ => panic!("invalid good"), + } + } + + fn get_list_by_path(&self, name: &str) -> &Entries { + match name { + _ if name.starts_with("common.items.crafting_ing.") => &self.ingredients, + _ if name.starts_with("common.items.armor.") => &self.armor, + _ if name.starts_with("common.items.glider.") => &self.other, + _ if name.starts_with("common.items.weapons.") => &self.tools, + _ if name.starts_with("common.items.consumable.") => &self.potions, + _ if name.starts_with("common.items.food.") => &self.food, + _ if name.starts_with("common.items.utility.") => &self.other, + _ if name.starts_with("common.items.boss_drops.") => &self.other, + _ if name.starts_with("common.items.ore.") => &self.ingredients, + _ if name.starts_with("common.items.flowers.") => &self.ingredients, + _ if name.starts_with("common.items.crafting_tools.") => &self.other, + _ => { + info!("unknown loot item {}", name); + &self.other + }, + } + } + + fn get_list_by_path_mut(&mut self, name: &str) -> &mut Entries { + match name { + _ if name.starts_with("common.items.crafting_ing.") => &mut self.ingredients, + _ if name.starts_with("common.items.armor.") => &mut self.armor, + _ if name.starts_with("common.items.glider.") => &mut self.other, + _ if name.starts_with("common.items.weapons.") => &mut self.tools, + _ if name.starts_with("common.items.consumable.") => &mut self.potions, + _ if name.starts_with("common.items.food.") => &mut self.food, + _ 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.ore.") => &mut self.ingredients, + _ if name.starts_with("common.items.flowers.") => &mut self.ingredients, + _ if name.starts_with("common.items.crafting_tools.") => &mut self.other, + _ => { + info!("unknown loot item {}", name); + &mut self.other + }, + } + } + + fn read() -> Self { + fn add(entryvec: &mut Entries, itemname: &str, probability: f32) { + let val = entryvec.iter_mut().find(|j| *j.0 == *itemname); + if let Some(r) = val { + if PRICING_DEBUG { + info!("Update {} {}+{}", r.0, r.1, probability); + } + r.1 += probability; + } else { + if PRICING_DEBUG { + info!("New {} {}", itemname, probability); + } + entryvec.push((itemname.to_string(), probability)); + } + } + fn sort_and_normalize(entryvec: &mut Entries, scale: f32) { + if !entryvec.is_empty() { + entryvec.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); + let rescale = scale / entryvec.last().unwrap().1; + for i in entryvec.iter_mut() { + i.1 *= rescale; + } + } + } + fn get_scaling(contents: &AssetGuard, good: Good) -> f32 { + contents + .good_scaling + .iter() + .find(|i| i.0 == good) + .map(|i| i.1) + .unwrap_or(1.0) + } + + let mut result = TradePricing::default(); + let files = TradingPriceFile::load_expect("common.item_price_calculation"); + let contents = files.read(); + for i in contents.loot_tables.iter() { + if PRICING_DEBUG { + info!(?i); + } + let loot = ProbabilityFile::load_expect(&i.1); + for j in loot.read().content.iter() { + add(&mut result.get_list_by_path_mut(&j.1), &j.1, i.0 * j.0); + } + } + + // Apply recipe book + let book = default_recipe_book().read(); + let mut ordered_recipes: Vec = Vec::new(); + for (_, r) in book.iter() { + ordered_recipes.push(RememberedRecipe { + output: r.output.0.id().into(), + amount: r.output.1, + material_cost: TradePricing::UNAVAILABLE_PRICE, + input: r + .inputs + .iter() + .filter(|i| matches!(i.0, RecipeInput::Item(_))) + .map(|i| { + ( + if let RecipeInput::Item(it) = &i.0 { + it.id().into() + } else { + panic!("recipe logic broken"); + }, + i.1, + ) + }) + .collect(), + }); + } + // look up price (inverse frequency) of an item + fn price_lookup(s: &TradePricing, name: &str) -> f32 { + let vec = s.get_list_by_path(name); + vec.iter() + .find(|(n, _)| n == name) + .map(|(_, freq)| 1.0 / freq) + // even if we multiply by INVEST_FACTOR we need to remain above UNAVAILABLE_PRICE (add 1.0 to compensate rounding errors) + .unwrap_or(TradePricing::UNAVAILABLE_PRICE/TradePricing::INVEST_FACTOR+1.0) + } + fn calculate_material_cost(s: &TradePricing, r: &RememberedRecipe) -> f32 { + r.input + .iter() + .map(|(name, amount)| { + price_lookup(s, name) * (*amount as f32).max(TradePricing::INVEST_FACTOR) + }) + .sum() + } + // re-look up prices and sort the vector by ascending material cost, return + // whether first cost is finite + fn price_sort(s: &TradePricing, vec: &mut Vec) -> bool { + if !vec.is_empty() { + for e in vec.iter_mut() { + e.material_cost = calculate_material_cost(s, e); + } + vec.sort_by(|a, b| a.material_cost.partial_cmp(&b.material_cost).unwrap()); + //info!(?vec); + vec.first().unwrap().material_cost < TradePricing::UNAVAILABLE_PRICE + } else { + false + } + } + // re-evaluate prices based on crafting tables + // (start with cheap ones to avoid changing material prices after evaluation) + while price_sort(&result, &mut ordered_recipes) { + ordered_recipes.retain(|e| { + if e.material_cost < TradePricing::UNAVAILABLE_PRICE { + let actual_cost = calculate_material_cost(&result, e); + add( + &mut result.get_list_by_path_mut(&e.output), + &e.output, + (e.amount as f32) / actual_cost * TradePricing::CRAFTING_FACTOR, + ); + false + } else { + true + } + }); + //info!(?ordered_recipes); + } + + let good_list = [ + Good::Armor, + Good::Tools, + Good::Potions, + Good::Food, + Good::Ingredients, + ]; + for &g in good_list.iter() { + sort_and_normalize(result.get_list_mut(g), get_scaling(&contents, g)); + let mut materials = result + .get_list(g) + .iter() + .map(|i| (i.0.clone(), (g, 1.0 / i.1))) + .collect::>(); + result.material_cache.extend(materials.drain(..)); + } + result.coin_scale = get_scaling(&contents, Good::Coin); + result + } + + fn random_item_impl(&self, good: Good, amount: f32) -> String { + if good == Good::Coin { + TradePricing::COIN_ITEM.into() + } else { + let table = self.get_list(good); + let upper = table.len(); + let lower = table + .iter() + .enumerate() + .find(|i| i.1.1 * amount >= 1.0) + .map(|i| i.0) + .unwrap_or(upper - 1); + let index = (rand::random::() * ((upper - lower) as f32)).floor() as usize + lower; + //.gen_range(lower..upper); + table.get(index).unwrap().0.clone() + } + } + + pub fn random_item(good: Good, amount: f32) -> String { + TRADE_PRICING.random_item_impl(good, amount) + } + + pub fn get_material(item: &str) -> (Good, f32) { + if item == TradePricing::COIN_ITEM { + (Good::Coin, 1.0 / TRADE_PRICING.coin_scale) + } else { + TRADE_PRICING + .material_cache + .get(item) + .cloned() + .unwrap_or((Good::Terrain(crate::terrain::BiomeKind::Void), 0.0)) + } + } + + #[cfg(test)] + fn instance() -> &'static Self { &TRADE_PRICING } + + #[cfg(test)] + fn print_sorted(&self) { + fn printvec(x: &str, e: &[(String, f32)]) { + println!("{}", x); + for i in e.iter() { + println!("{} {}", i.0, 1.0 / i.1); + } + } + printvec("Armor", &self.armor); + printvec("Tools", &self.tools); + printvec("Potions", &self.potions); + printvec("Food", &self.food); + printvec("Ingredients", &self.ingredients); + println!("{} {}", TradePricing::COIN_ITEM, self.coin_scale); + } +} + +#[cfg(test)] +mod tests { + use crate::{comp::inventory::trade_pricing::TradePricing, trade::Good}; + use tracing::{info, Level}; + use tracing_subscriber::{ + filter::{EnvFilter, LevelFilter}, + FmtSubscriber, + }; + + fn init() { + FmtSubscriber::builder() + .with_max_level(Level::ERROR) + .with_env_filter(EnvFilter::from_default_env().add_directive(LevelFilter::INFO.into())) + .init(); + } + + #[test] + fn test_prices() { + init(); + info!("init"); + + TradePricing::instance().print_sorted(); + info!("Armor 5 {}", TradePricing::random_item(Good::Armor, 5.0)); + info!("Armor 5 {}", TradePricing::random_item(Good::Armor, 5.0)); + info!("Armor 5 {}", TradePricing::random_item(Good::Armor, 5.0)); + info!("Armor 5 {}", TradePricing::random_item(Good::Armor, 5.0)); + info!("Armor 5 {}", TradePricing::random_item(Good::Armor, 5.0)); + } +} diff --git a/common/src/event.rs b/common/src/event.rs index 350649530e..21c8bc33af 100644 --- a/common/src/event.rs +++ b/common/src/event.rs @@ -16,6 +16,8 @@ use specs::Entity as EcsEntity; use std::{collections::VecDeque, ops::DerefMut, sync::Mutex}; use vek::*; +pub type SiteId = u64; + pub enum LocalEvent { /// Applies upward force to entity's `Vel` Jump(EcsEntity), @@ -148,6 +150,10 @@ pub enum ServerEvent { entity: EcsEntity, change: i32, }, + RequestSiteInfo { + entity: EcsEntity, + id: SiteId, + }, } pub struct EventBus { diff --git a/common/src/generation.rs b/common/src/generation.rs index b1568cefc6..9b187ecc05 100644 --- a/common/src/generation.rs +++ b/common/src/generation.rs @@ -2,6 +2,7 @@ use crate::{ comp::{self, humanoid, inventory::loadout_builder::LoadoutConfig, Alignment, Body, Item}, npc::{self, NPC_NAMES}, skillset_builder::SkillSetConfig, + trade::SiteInformation, }; use vek::*; @@ -27,6 +28,9 @@ pub struct EntityInfo { pub loadout_config: Option, pub skillset_config: Option, pub pet: Option>, + // we can't use DHashMap, do we want to move that into common? + pub trading_information: Option, + //Option>, /* price and available amount */ } impl EntityInfo { @@ -47,6 +51,7 @@ impl EntityInfo { loadout_config: None, skillset_config: None, pet: None, + trading_information: None, } } @@ -151,6 +156,12 @@ impl EntityInfo { }); self } + + // map contains price+amount + pub fn with_economy(mut self, e: &SiteInformation) -> Self { + self.trading_information = Some(e.clone()); + self + } } #[derive(Default)] diff --git a/common/src/skillset_builder.rs b/common/src/skillset_builder.rs index 81b039d882..33c28ef77c 100644 --- a/common/src/skillset_builder.rs +++ b/common/src/skillset_builder.rs @@ -15,6 +15,7 @@ pub enum SkillSetConfig { Myrmidon, Guard, Villager, + Merchant, Outcast, Highwayman, Bandit, @@ -255,7 +256,7 @@ impl SkillSetBuilder { _ => Self::default(), } }, - Some(Bandit) => { + Some(Bandit) | Some(Merchant) => { match active_item { Some(ToolKind::Sword) => { // Sword diff --git a/common/src/store.rs b/common/src/store.rs index 75ce9dc427..a68c447b3c 100644 --- a/common/src/store.rs +++ b/common/src/store.rs @@ -5,6 +5,7 @@ use std::{ ops::{Index, IndexMut}, }; +/// Type safe index into Store pub struct Id { idx: u32, gen: u32, @@ -58,6 +59,8 @@ struct Entry { item: Option, } +/// A general-purpose high performance allocator, basically Vec with type safe +/// indices (Id) pub struct Store { entries: Vec>, len: usize, @@ -183,6 +186,22 @@ impl Store { None } } + + pub fn recreate_id(&self, i: u64) -> Option> { + if i as usize >= self.entries.len() { + None + } else { + Some(Id { + idx: i as u32, + gen: self + .entries + .get(i as usize) + .map(|e| e.gen) + .unwrap_or_default(), + phantom: PhantomData, + }) + } + } } impl Index> for Store { diff --git a/common/src/terrain/biome.rs b/common/src/terrain/biome.rs index 67661b13a3..9c0ce56e87 100644 --- a/common/src/terrain/biome.rs +++ b/common/src/terrain/biome.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub enum BiomeKind { Void, Lake, diff --git a/common/src/trade.rs b/common/src/trade.rs index d82b085bde..a5ec16a065 100644 --- a/common/src/trade.rs +++ b/common/src/trade.rs @@ -1,5 +1,6 @@ use crate::{ comp::inventory::{slot::InvSlotId, Inventory}, + terrain::BiomeKind, uid::Uid, }; use hashbrown::HashMap; @@ -291,3 +292,71 @@ impl Default for Trades { } } } + +// we need this declaration in common for Merchant loadout creation, it is not +// directly related to trade between entities, but between sites (more abstract) +// economical information +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub enum Good { + Territory(BiomeKind), + Flour, + Meat, + Terrain(BiomeKind), + Transportation, + Food, + Wood, + Stone, + Tools, // weapons, farming tools + Armor, + Ingredients, // raw material for Armor+Tools+Potions + Potions, + Coin, // exchange material across sites + RoadSecurity, +} + +impl Default for Good { + fn default() -> Self { + Good::Terrain(crate::terrain::BiomeKind::Void) // Arbitrary + } +} + +// ideally this would be a real Id but that is from the world crate +pub type SiteId = u64; + +#[derive(Clone, Debug)] +pub struct SiteInformation { + pub id: SiteId, + pub unconsumed_stock: HashMap, +} + +#[derive(Clone, Debug, Default)] +pub struct SitePrices { + pub values: HashMap, +} + +#[derive(Clone, Debug, Default)] +pub struct ReducedInventoryItem { + pub name: String, + pub amount: u32, +} + +#[derive(Clone, Debug, Default)] +pub struct ReducedInventory { + pub inventory: HashMap, +} + +impl ReducedInventory { + pub fn from(inventory: &Inventory) -> Self { + let items = inventory + .slots_with_id() + .filter(|(_, it)| it.is_some()) + .map(|(sl, it)| { + (sl, ReducedInventoryItem { + name: it.as_ref().unwrap().item_definition_id().to_string(), + amount: it.as_ref().unwrap().amount(), + }) + }) + .collect(); + Self { inventory: items } + } +} diff --git a/server/src/client.rs b/server/src/client.rs index 7f571f4a58..3abbf49c9a 100644 --- a/server/src/client.rs +++ b/server/src/client.rs @@ -88,6 +88,7 @@ impl Client { | ServerGeneral::ExitInGameSuccess | ServerGeneral::InventoryUpdate(_, _) | ServerGeneral::SetViewDistance(_) + | ServerGeneral::SiteEconomy(_) | ServerGeneral::Outcomes(_) | ServerGeneral::Knockback(_) | ServerGeneral::UpdatePendingTrade(_, _) @@ -160,6 +161,7 @@ impl Client { | ServerGeneral::SetViewDistance(_) | ServerGeneral::Outcomes(_) | ServerGeneral::Knockback(_) + | ServerGeneral::SiteEconomy(_) | ServerGeneral::UpdatePendingTrade(_, _) | ServerGeneral::FinishedTrade(_) => { PreparedMsg::new(2, &g, &self.in_game_stream) diff --git a/server/src/cmd.rs b/server/src/cmd.rs index 20472f66ff..6d97c0b43e 100644 --- a/server/src/cmd.rs +++ b/server/src/cmd.rs @@ -841,7 +841,8 @@ fn handle_spawn( let body = body(); - let loadout = LoadoutBuilder::build_loadout(body, None, None).build(); + let loadout = + LoadoutBuilder::build_loadout(body, None, None, None).build(); let inventory = Inventory::new_with_loadout(loadout); diff --git a/server/src/events/information.rs b/server/src/events/information.rs new file mode 100644 index 0000000000..2125f61d02 --- /dev/null +++ b/server/src/events/information.rs @@ -0,0 +1,87 @@ +use crate::{client::Client, Server}; +use common_net::msg::{world_msg::EconomyInfo, ServerGeneral}; +use specs::{Entity as EcsEntity, WorldExt}; +use std::collections::HashMap; + +#[cfg(not(feature = "worldgen"))] +pub fn handle_site_info(server: &Server, entity: EcsEntity, id: u64) { + let info = EconomyInfo { + id, + population: 0, + stock: HashMap::new(), + labor_values: HashMap::new(), + values: HashMap::new(), + labors: Vec::new(), + }; + let msg = ServerGeneral::SiteEconomy(info); + server + .state + .ecs() + .read_storage::() + .get(entity) + .map(|c| c.send(msg)); +} + +#[cfg(feature = "worldgen")] +pub fn handle_site_info(server: &Server, entity: EcsEntity, id: u64) { + let site_id = server.index.sites.recreate_id(id); + let info = if let Some(site_id) = site_id { + let site = server.index.sites.get(site_id); + EconomyInfo { + id, + population: site.economy.pop.floor() as u32, + stock: site.economy.stocks.iter().map(|(g, a)| (g, *a)).collect(), + labor_values: site + .economy + .labor_values + .iter() + .filter(|a| a.1.is_some()) + .map(|(g, a)| (g, a.unwrap())) + .collect(), + values: site + .economy + .values + .iter() + .filter(|a| a.1.is_some()) + .map(|(g, a)| (g, a.unwrap())) + .collect(), + labors: site.economy.labors.iter().map(|(_, a)| (*a)).collect(), + last_exports: site + .economy + .last_exports + .iter() + .map(|(g, a)| (g, *a)) + .collect(), + resources: site + .economy + .natural_resources + .chunks_per_resource + .iter() + .map(|(g, a)| { + ( + g, + ((*a) as f32) * site.economy.natural_resources.average_yield_per_chunk[g], + ) + }) + .collect(), + } + } else { + EconomyInfo { + id, + population: 0, + stock: HashMap::new(), + labor_values: HashMap::new(), + values: HashMap::new(), + labors: Vec::new(), + last_exports: HashMap::new(), + resources: HashMap::new(), + } + }; + let msg = ServerGeneral::SiteEconomy(info); + server + .state + .ecs() + .read_storage::() + .get(entity) + .map(|c| c.send(msg)); +} diff --git a/server/src/events/invite.rs b/server/src/events/invite.rs index 6a7c36dfa2..d7dac789b4 100644 --- a/server/src/events/invite.rs +++ b/server/src/events/invite.rs @@ -3,6 +3,7 @@ use crate::{client::Client, Server}; use common::{ comp::{ self, + agent::AgentEvent, group::GroupManager, invite::{Invite, InviteKind, InviteResponse, PendingInvites}, ChatType, @@ -72,7 +73,7 @@ pub fn handle_invite( } } - let agents = state.ecs().read_storage::(); + let mut agents = state.ecs().write_storage::(); let mut invites = state.ecs().write_storage::(); if invites.contains(invitee) { @@ -128,8 +129,13 @@ pub fn handle_invite( kind, }); } - } else if agents.contains(invitee) { - send_invite(); + } else if let Some(agent) = agents.get_mut(invitee) { + if send_invite() { + if let Some(inviter) = uids.get(inviter) { + agent.inbox.push_front(AgentEvent::TradeInvite(*inviter)); + invite_sent = true; + } + } } else if let Some(client) = clients.get(inviter) { client.send_fallible(ServerGeneral::server_msg( ChatType::Meta, diff --git a/server/src/events/mod.rs b/server/src/events/mod.rs index ead348a5d4..b80d7d8f43 100644 --- a/server/src/events/mod.rs +++ b/server/src/events/mod.rs @@ -11,6 +11,7 @@ use entity_manipulation::{ handle_respawn, }; use group_manip::handle_group; +use information::handle_site_info; use interaction::{ handle_lantern, handle_mount, handle_npc_interaction, handle_possess, handle_unmount, }; @@ -23,6 +24,7 @@ use trade::handle_process_trade_action; mod entity_creation; mod entity_manipulation; mod group_manip; +mod information; mod interaction; mod inventory_manip; mod invite; @@ -187,6 +189,7 @@ impl Server { ServerEvent::ComboChange { entity, change } => { handle_combo_change(&self, entity, change) }, + ServerEvent::RequestSiteInfo { entity, id } => handle_site_info(&self, entity, id), } } diff --git a/server/src/events/trade.rs b/server/src/events/trade.rs index 12190921ed..ef91cfb9fe 100644 --- a/server/src/events/trade.rs +++ b/server/src/events/trade.rs @@ -1,7 +1,10 @@ use crate::Server; use common::{ - comp::inventory::{item::MaterialStatManifest, Inventory}, - trade::{PendingTrade, TradeAction, TradeId, TradeResult, Trades}, + comp::{ + agent::{Agent, AgentEvent}, + inventory::{item::MaterialStatManifest, Inventory}, + }, + trade::{PendingTrade, ReducedInventory, TradeAction, TradeId, TradeResult, Trades}, }; use common_net::{ msg::ServerGeneral, @@ -11,6 +14,42 @@ use hashbrown::hash_map::Entry; use specs::{world::WorldExt, Entity as EcsEntity}; use std::cmp::Ordering; use tracing::{error, trace}; +use world::IndexOwned; + +fn notify_agent_simple( + mut agents: specs::WriteStorage, + entity: EcsEntity, + event: AgentEvent, +) { + if let Some(agent) = agents.get_mut(entity) { + agent.inbox.push_front(event); + } +} + +fn notify_agent_prices( + mut agents: specs::WriteStorage, + index: &IndexOwned, + entity: EcsEntity, + event: AgentEvent, +) { + if let Some(agent) = agents.get_mut(entity) { + if let AgentEvent::UpdatePendingTrade(boxval) = event { + // Box<(tid, pend, _, inventories)>) = event { + let prices = agent + .trade_for_site + .map(|i| index.sites.recreate_id(i)) + .flatten() + .map(|i| index.sites.get(i)) + .map(|s| s.economy.get_site_prices()) + .unwrap_or_default(); + agent + .inbox + .push_front(AgentEvent::UpdatePendingTrade(Box::new(( + boxval.0, boxval.1, prices, boxval.3, + )))); + } + } +} /// Invoked when the trade UI is up, handling item changes, accepts, etc pub fn handle_process_trade_action( @@ -26,7 +65,12 @@ pub fn handle_process_trade_action( to_notify .and_then(|u| server.state.ecs().entity_from_uid(u.0)) .map(|e| { - server.notify_client(e, ServerGeneral::FinishedTrade(TradeResult::Declined)) + server.notify_client(e, ServerGeneral::FinishedTrade(TradeResult::Declined)); + notify_agent_simple( + server.state.ecs().write_storage::(), + e, + AgentEvent::FinishedTrade(TradeResult::Declined), + ); }); } else { { @@ -43,20 +87,54 @@ pub fn handle_process_trade_action( } if let Entry::Occupied(entry) = trades.trades.entry(trade_id) { let parties = entry.get().parties; - let msg = if entry.get().should_commit() { + if entry.get().should_commit() { let result = commit_trade(server.state.ecs(), entry.get()); entry.remove(); - ServerGeneral::FinishedTrade(result) + for party in parties.iter() { + if let Some(e) = server.state.ecs().entity_from_uid(party.0) { + server.notify_client(e, ServerGeneral::FinishedTrade(result.clone())); + notify_agent_simple( + server.state.ecs().write_storage::(), + e, + AgentEvent::FinishedTrade(result.clone()), + ); + } + } } else { - ServerGeneral::UpdatePendingTrade(trade_id, entry.get().clone()) - }; - // send the updated state to both parties - for party in parties.iter() { - server - .state - .ecs() - .entity_from_uid(party.0) - .map(|e| server.notify_client(e, msg.clone())); + let mut entities: [Option; 2] = [None, None]; + let mut inventories: [Option; 2] = [None, None]; + // sadly there is no map and collect on arrays + for i in 0..2 { + // parties.len()) { + entities[i] = server.state.ecs().entity_from_uid(parties[i].0); + if let Some(e) = entities[i] { + inventories[i] = server + .state + .ecs() + .read_component::() + .get(e) + .map(|i| ReducedInventory::from(i)); + } + } + for party in entities.iter() { + if let Some(e) = *party { + server.notify_client( + e, + ServerGeneral::UpdatePendingTrade(trade_id, entry.get().clone()), + ); + notify_agent_prices( + server.state.ecs().write_storage::(), + &server.index, + e, + AgentEvent::UpdatePendingTrade(Box::new(( + trade_id, + entry.get().clone(), + Default::default(), + inventories.clone(), + ))), + ); + } + } } } } diff --git a/server/src/rtsim/entity.rs b/server/src/rtsim/entity.rs index cfd29f9a3e..a65a555890 100644 --- a/server/src/rtsim/entity.rs +++ b/server/src/rtsim/entity.rs @@ -114,7 +114,7 @@ impl Entity { "common.items.armor.swift.shoulder", )); - LoadoutBuilder::build_loadout(self.get_body(), Some(main_tool), None) + LoadoutBuilder::build_loadout(self.get_body(), Some(main_tool), None, None) .back(back) .lantern(lantern) .chest(chest) diff --git a/server/src/rtsim/tick.rs b/server/src/rtsim/tick.rs index 0ae3503f11..6faddda0c1 100644 --- a/server/src/rtsim/tick.rs +++ b/server/src/rtsim/tick.rs @@ -114,6 +114,7 @@ impl<'a> System<'a> for Sys { agent: Some(comp::Agent::new( None, matches!(body, comp::Body::Humanoid(_)), + None, &body, false, )), diff --git a/server/src/sys/agent.rs b/server/src/sys/agent.rs index 40059f5a28..daef971747 100644 --- a/server/src/sys/agent.rs +++ b/server/src/sys/agent.rs @@ -1,9 +1,9 @@ use common::{ comp::{ self, - agent::{AgentEvent, Tactic, Target, DEFAULT_INTERACTION_TIME}, + agent::{AgentEvent, Tactic, Target, DEFAULT_INTERACTION_TIME, TRADE_INTERACTION_TIME}, group, - inventory::slot::EquipSlot, + inventory::{slot::EquipSlot, trade_pricing::TradePricing}, invite::InviteResponse, item::{ tool::{ToolKind, UniqueKind}, @@ -19,6 +19,7 @@ use common::{ resources::{DeltaTime, TimeOfDay}, terrain::{Block, TerrainGrid}, time::DayPeriod, + trade::{Good, TradeAction, TradePhase, TradeResult}, uid::{Uid, UidAllocator}, util::Dir, vol::ReadVol, @@ -79,7 +80,6 @@ pub struct ReadData<'a> { alignments: ReadStorage<'a, Alignment>, bodies: ReadStorage<'a, Body>, mount_states: ReadStorage<'a, MountState>, - //ReadStorage<'a, Invite>, time_of_day: Read<'a, TimeOfDay>, light_emitter: ReadStorage<'a, LightEmitter>, } @@ -456,7 +456,13 @@ impl<'a> AgentData<'a> { agent.action_timer = 0.1; } if agent.action_timer > 0.0 { - if agent.action_timer < DEFAULT_INTERACTION_TIME { + if agent.action_timer + < (if agent.trading { + TRADE_INTERACTION_TIME + } else { + DEFAULT_INTERACTION_TIME + }) + { self.interact(agent, controller, &read_data, event_emitter); } else { agent.action_timer = 0.0; @@ -691,71 +697,201 @@ impl<'a> AgentData<'a> { ) { // TODO: Process group invites // TODO: Add Group AgentEvent - let accept = false; // set back to "matches!(alignment, Alignment::Npc)" when we got better NPC recruitment mechanics - if accept { - // Clear agent comp - *agent = Agent::default(); - controller - .events - .push(ControlEvent::InviteResponse(InviteResponse::Accept)); - } else { - controller - .events - .push(ControlEvent::InviteResponse(InviteResponse::Decline)); - } + // let accept = false; // set back to "matches!(alignment, Alignment::Npc)" + // when we got better NPC recruitment mechanics if accept { + // // Clear agent comp + // //*agent = Agent::default(); + // controller + // .events + // .push(ControlEvent::InviteResponse(InviteResponse::Accept)); + // } else { + // controller + // .events + // .push(ControlEvent::InviteResponse(InviteResponse::Decline)); + // } agent.action_timer += read_data.dt.0; - if agent.can_speak { - if let Some(AgentEvent::Talk(by)) = agent.inbox.pop_back() { - if let Some(target) = read_data.uid_allocator.retrieve_entity_internal(by.id()) { - agent.target = Some(Target { - target, - hostile: false, - }); - if let Some(tgt_pos) = read_data.positions.get(target) { - let eye_offset = self.body.map_or(0.0, |b| b.eye_height()); - let tgt_eye_offset = - read_data.bodies.get(target).map_or(0.0, |b| b.eye_height()); - if let Some(dir) = Dir::from_unnormalized( - Vec3::new(tgt_pos.0.x, tgt_pos.0.y, tgt_pos.0.z + tgt_eye_offset) - - Vec3::new(self.pos.0.x, self.pos.0.y, self.pos.0.z + eye_offset), - ) { - controller.inputs.look_dir = dir; - } - controller.actions.push(ControlAction::Stand); - controller.actions.push(ControlAction::Talk); - if let Some((_travel_to, destination_name)) = - &agent.rtsim_controller.travel_to - { - let msg = - format!("I'm heading to {}! Want to come along?", destination_name); - event_emitter - .emit(ServerEvent::Chat(UnresolvedChatMsg::npc(*self.uid, msg))); - } else { - let msg = "npc.speech.villager".to_string(); - event_emitter - .emit(ServerEvent::Chat(UnresolvedChatMsg::npc(*self.uid, msg))); + let msg = agent.inbox.pop_back(); + match msg { + Some(AgentEvent::Talk(by)) => { + if agent.can_speak { + if let Some(target) = read_data.uid_allocator.retrieve_entity_internal(by.id()) + { + agent.target = Some(Target { + target, + hostile: false, + }); + if let Some(tgt_pos) = read_data.positions.get(target) { + let eye_offset = self.body.map_or(0.0, |b| b.eye_height()); + let tgt_eye_offset = + read_data.bodies.get(target).map_or(0.0, |b| b.eye_height()); + if let Some(dir) = Dir::from_unnormalized( + Vec3::new(tgt_pos.0.x, tgt_pos.0.y, tgt_pos.0.z + tgt_eye_offset) + - Vec3::new( + self.pos.0.x, + self.pos.0.y, + self.pos.0.z + eye_offset, + ), + ) { + controller.inputs.look_dir = dir; + } + controller.actions.push(ControlAction::Stand); + controller.actions.push(ControlAction::Talk); + if let Some((_travel_to, destination_name)) = + &agent.rtsim_controller.travel_to + { + let msg = format!( + "I'm heading to {}! Want to come along?", + destination_name + ); + event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc( + *self.uid, msg, + ))); + } else { + let msg = "npc.speech.villager".to_string(); + event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc( + *self.uid, msg, + ))); + } } } } - } else if let Some(Target { target, .. }) = &agent.target { - if let Some(tgt_pos) = read_data.positions.get(*target) { - let eye_offset = self.body.map_or(0.0, |b| b.eye_height()); - let tgt_eye_offset = read_data - .bodies - .get(*target) - .map_or(0.0, |b| b.eye_height()); - if let Some(dir) = Dir::from_unnormalized( - Vec3::new(tgt_pos.0.x, tgt_pos.0.y, tgt_pos.0.z + tgt_eye_offset) - - Vec3::new(self.pos.0.x, self.pos.0.y, self.pos.0.z + eye_offset), - ) { - controller.inputs.look_dir = dir; + }, + Some(AgentEvent::TradeInvite(_with)) => { + if agent.trade_for_site.is_some() && !agent.trading { + // stand still and looking towards the trading player + controller.actions.push(ControlAction::Stand); + controller.actions.push(ControlAction::Talk); + controller + .events + .push(ControlEvent::InviteResponse(InviteResponse::Accept)); + agent.trading = true; + } else { + // TODO: Provide a hint where to find the closest merchant? + controller + .events + .push(ControlEvent::InviteResponse(InviteResponse::Decline)); + } + }, + Some(AgentEvent::FinishedTrade(result)) => { + if agent.trading { + match result { + TradeResult::Completed => { + event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc( + *self.uid, + "Thank you for trading with me!".to_string(), + ))) + }, + _ => event_emitter.emit(ServerEvent::Chat(UnresolvedChatMsg::npc( + *self.uid, + "Maybe another time, have a good day!".to_string(), + ))), + } + agent.trading = false; + } + }, + Some(AgentEvent::UpdatePendingTrade(boxval)) => { + let (tradeid, pending, prices, inventories) = *boxval; + if agent.trading { + // I assume player is [0], agent is [1] + fn trade_margin(g: Good) -> f32 { + match g { + Good::Tools | Good::Armor => 0.5, + Good::Food | Good::Potions | Good::Ingredients => 0.75, + Good::Coin => 1.0, + _ => 0.0, // what is this? + } + } + let balance0: f32 = pending.offers[0] + .iter() + .map(|(slot, amount)| { + inventories[0] + .as_ref() + .map(|ri| { + ri.inventory.get(slot).map(|item| { + let (material, factor) = + TradePricing::get_material(&item.name); + prices.values.get(&material).cloned().unwrap_or_default() + * factor + * (*amount as f32) + * trade_margin(material) + }) + }) + .flatten() + .unwrap_or_default() + }) + .sum(); + let balance1: f32 = pending.offers[1] + .iter() + .map(|(slot, amount)| { + inventories[1] + .as_ref() + .map(|ri| { + ri.inventory.get(slot).map(|item| { + let (material, factor) = + TradePricing::get_material(&item.name); + prices.values.get(&material).cloned().unwrap_or_default() + * factor + * (*amount as f32) + }) + }) + .flatten() + .unwrap_or_default() + }) + .sum(); + tracing::debug!("UpdatePendingTrade({}, {})", balance0, balance1); + if balance0 >= balance1 { + event_emitter.emit(ServerEvent::ProcessTradeAction( + *self.entity, + tradeid, + TradeAction::Accept(pending.phase), + )); + } else { + if balance1 > 0.0 { + let msg = format!( + "That only covers {:.1}% of my costs!", + balance0 / balance1 * 100.0 + ); + event_emitter + .emit(ServerEvent::Chat(UnresolvedChatMsg::npc(*self.uid, msg))); + } + if pending.phase != TradePhase::Mutate { + // we got into the review phase but without balanced goods, decline + event_emitter.emit(ServerEvent::ProcessTradeAction( + *self.entity, + tradeid, + TradeAction::Decline, + )); + } } } - } else { - agent.action_timer = 0.0; - } - } else { - agent.inbox.clear(); + }, + None => { + if agent.can_speak { + // no new events, continue looking towards the last interacting player for some + // time + if let Some(Target { target, .. }) = &agent.target { + if let Some(tgt_pos) = read_data.positions.get(*target) { + let eye_offset = self.body.map_or(0.0, |b| b.eye_height()); + let tgt_eye_offset = read_data + .bodies + .get(*target) + .map_or(0.0, |b| b.eye_height()); + if let Some(dir) = Dir::from_unnormalized( + Vec3::new(tgt_pos.0.x, tgt_pos.0.y, tgt_pos.0.z + tgt_eye_offset) + - Vec3::new( + self.pos.0.x, + self.pos.0.y, + self.pos.0.z + eye_offset, + ), + ) { + controller.inputs.look_dir = dir; + } + } + } else { + agent.action_timer = 0.0; + } + } + }, } } diff --git a/server/src/sys/msg/in_game.rs b/server/src/sys/msg/in_game.rs index edcb7077c6..bcd2f3b90c 100644 --- a/server/src/sys/msg/in_game.rs +++ b/server/src/sys/msg/in_game.rs @@ -126,6 +126,9 @@ impl Sys { .get_mut(entity) .map(|mut s| s.skill_set.unlock_skill_group(skill_group_kind)); }, + ClientGeneral::RequestSiteInfo(id) => { + server_emitter.emit(ServerEvent::RequestSiteInfo { entity, id }); + }, _ => tracing::error!("not a client_in_game msg"), } Ok(()) diff --git a/server/src/sys/terrain.rs b/server/src/sys/terrain.rs index e0e3bc5247..24746c518d 100644 --- a/server/src/sys/terrain.rs +++ b/server/src/sys/terrain.rs @@ -2,7 +2,7 @@ use crate::{ chunk_generator::ChunkGenerator, client::Client, presence::Presence, rtsim::RtSim, Tick, }; use common::{ - comp::{self, bird_medium, Alignment, Pos}, + comp::{self, bird_medium, inventory::loadout_builder::LoadoutConfig, Alignment, Pos}, event::{EventBus, ServerEvent}, generation::get_npc_name, npc::NPC_NAMES, @@ -143,12 +143,13 @@ impl<'a> System<'a> for Sys { } let loadout_config = entity.loadout_config; + let economy = entity.trading_information.as_ref(); let skillset_config = entity.skillset_config; stats.skill_set = SkillSetBuilder::build_skillset(&main_tool, skillset_config).build(); let loadout = - LoadoutBuilder::build_loadout(body, main_tool, loadout_config).build(); + LoadoutBuilder::build_loadout(body, main_tool, loadout_config, economy).build(); let health = comp::Health::new(body, entity.level.unwrap_or(0)); let poise = comp::Poise::new(body); @@ -162,6 +163,11 @@ impl<'a> System<'a> for Sys { }, _ => false, }; + let trade_for_site = if matches!(loadout_config, Some(LoadoutConfig::Merchant)) { + economy.map(|e| e.id) + } else { + None + }; // TODO: This code sets an appropriate base_damage for the enemy. This doesn't // work because the damage is now saved in an ability @@ -182,6 +188,7 @@ impl<'a> System<'a> for Sys { Some(comp::Agent::new( Some(entity.pos), can_speak, + trade_for_site, &body, matches!( loadout_config, diff --git a/voxygen/src/hud/map.rs b/voxygen/src/hud/map.rs index 10f40bb69c..6975634ab2 100644 --- a/voxygen/src/hud/map.rs +++ b/voxygen/src/hud/map.rs @@ -8,9 +8,9 @@ use crate::{ ui::{fonts::Fonts, img_ids, ImageFrame, Tooltip, TooltipManager, Tooltipable}, GlobalState, }; -use client::{self, Client}; -use common::{comp, comp::group::Role, terrain::TerrainChunkSize, vol::RectVolSize}; -use common_net::msg::world_msg::SiteKind; +use client::{self, Client, SiteInfoRich}; +use common::{comp, comp::group::Role, terrain::TerrainChunkSize, trade::Good, vol::RectVolSize}; +use common_net::msg::world_msg::{SiteId, SiteKind}; use conrod_core::{ color, position, widget::{self, Button, Image, Rectangle, Text}, @@ -66,6 +66,8 @@ widget_ids! { } } +const SHOW_ECONOMY: bool = false; // turn this display off (for 0.9) until we have an improved look + #[derive(WidgetCommon)] pub struct Map<'a> { client: &'a Client, @@ -122,6 +124,40 @@ pub enum Event { ShowCaves(bool), ShowTrees(bool), Close, + RequestSiteInfo(SiteId), +} + +fn get_site_economy(site_rich: &SiteInfoRich) -> String { + if SHOW_ECONOMY { + let site = &site_rich.site; + if let Some(economy) = &site_rich.economy { + use common::trade::Good::{Armor, Coin, Food, Ingredients, Potions, Tools}; + let mut result = format!("\n\nPopulation {:?}", economy.population); + result += "\nStock"; + for i in [Food, Potions, Ingredients, Coin, Tools, Armor].iter() { + result += &format!(" {:?}={:.1}", *i, *economy.stock.get(i).unwrap_or(&0.0)); + } + result += "\nPrice"; + for i in [Food, Potions, Ingredients, Coin, Tools, Armor].iter() { + result += &format!(" {:?}={:.1}", *i, *economy.values.get(i).unwrap_or(&0.0)); + } + + let mut trade_sorted: Vec<(&Good, &f32)> = economy.last_exports.iter().collect(); + trade_sorted.sort_unstable_by(|a, b| a.1.partial_cmp(b.1).unwrap()); + if trade_sorted.first().is_some() { + result += &format!("\nTrade {:.1} ", *(trade_sorted.first().unwrap().1)); + for i in trade_sorted.iter().filter(|x| *x.1 != 0.0) { + result += &format!("{:?} ", i.0); + } + result += &format!("{:.1}", *(trade_sorted.last().unwrap().1)); + } + result + } else { + format!("\nloading economy for\n{:?}", site.id) + } + } else { + "".into() + } } impl<'a> Widget for Map<'a> { @@ -527,7 +563,8 @@ impl<'a> Widget for Map<'a> { .resize(self.client.sites().len(), &mut ui.widget_id_generator()) }); } - for (i, site) in self.client.sites().iter().enumerate() { + for (i, site_rich) in self.client.sites().values().enumerate() { + let site = &site_rich.site; // Site pos in world coordinates relative to the player let rwpos = site.wpos.map(|e| e as f32) - player_pos; // Convert to chunk coordinates @@ -564,6 +601,7 @@ impl<'a> Widget for Map<'a> { SiteKind::Cave => (0, i18n.get("hud.map.cave").to_string()), SiteKind::Tree => (0, i18n.get("hud.map.tree").to_string()), }; + let desc = desc + &get_site_economy(site_rich); let site_btn = Button::image(match &site.kind { SiteKind::Town => self.imgs.mmap_site_town, SiteKind::Dungeon { .. } => self.imgs.mmap_site_dungeon, @@ -607,32 +645,19 @@ impl<'a> Widget for Map<'a> { }, ); // Only display sites that are toggled on - match &site.kind { - SiteKind::Town => { - if show_towns { - site_btn.set(state.ids.mmap_site_icons[i], ui); - } - }, - SiteKind::Dungeon { .. } => { - if show_dungeons { - site_btn.set(state.ids.mmap_site_icons[i], ui); - } - }, - SiteKind::Castle => { - if show_castles { - site_btn.set(state.ids.mmap_site_icons[i], ui); - } - }, - SiteKind::Cave => { - if show_caves { - site_btn.set(state.ids.mmap_site_icons[i], ui); - } - }, - SiteKind::Tree => { - if show_trees { - site_btn.set(state.ids.mmap_site_icons[i], ui); - } - }, + let show_site = match &site.kind { + SiteKind::Town => show_towns, + SiteKind::Dungeon { .. } => show_dungeons, + SiteKind::Castle => show_castles, + SiteKind::Cave => show_caves, + SiteKind::Tree => show_trees, + }; + if show_site { + let tooltip_visible = site_btn.set_ext(state.ids.mmap_site_icons[i], ui).1; + + if SHOW_ECONOMY && tooltip_visible && site_rich.economy.is_none() { + events.push(Event::RequestSiteInfo(site.id)); + } } // Difficulty from 0-6 diff --git a/voxygen/src/hud/minimap.rs b/voxygen/src/hud/minimap.rs index 6f93ef9ca2..ceee112fba 100644 --- a/voxygen/src/hud/minimap.rs +++ b/voxygen/src/hud/minimap.rs @@ -277,7 +277,8 @@ impl<'a> Widget for MiniMap<'a> { .resize(self.client.sites().len(), &mut ui.widget_id_generator()) }); } - for (i, site) in self.client.sites().iter().enumerate() { + for (i, site_rich) in self.client.sites().values().enumerate() { + let site = &site_rich.site; // Site pos in world coordinates relative to the player let rwpos = site.wpos.map(|e| e as f32) - player_pos; // Convert to chunk coordinates diff --git a/voxygen/src/hud/mod.rs b/voxygen/src/hud/mod.rs index 35a7b34553..f430902e6e 100644 --- a/voxygen/src/hud/mod.rs +++ b/voxygen/src/hud/mod.rs @@ -77,7 +77,7 @@ use common::{ vol::RectRasterableVol, }; use common_base::span; -use common_net::msg::{Notification, PresenceKind}; +use common_net::msg::{world_msg::SiteId, Notification, PresenceKind}; use conrod_core::{ text::cursor::Index, widget::{self, Button, Image, Text}, @@ -433,6 +433,7 @@ pub enum Event { UnlockSkill(Skill), MinimapShow(bool), MinimapFaceNorth(bool), + RequestSiteInfo(SiteId), } // TODO: Are these the possible layouts we want? @@ -2689,6 +2690,9 @@ impl Hud { map::Event::ShowTrees(map_show_trees) => { events.push(Event::MapShowTrees(map_show_trees)); }, + map::Event::RequestSiteInfo(id) => { + events.push(Event::RequestSiteInfo(id)); + }, } } } else { diff --git a/voxygen/src/session.rs b/voxygen/src/session.rs index 99ed713710..0617b607b8 100644 --- a/voxygen/src/session.rs +++ b/voxygen/src/session.rs @@ -1274,6 +1274,10 @@ impl PlayState for SessionState { global_state.settings.interface.map_show_trees = map_show_trees; global_state.settings.save_to_file_warn(); }, + HudEvent::RequestSiteInfo(id) => { + let mut client = self.client.borrow_mut(); + client.request_site_economy(id); + }, HudEvent::ChangeGamma(new_gamma) => { global_state.settings.graphics.gamma = new_gamma; global_state.settings.save_to_file_warn(); diff --git a/voxygen/src/ui/widgets/tooltip.rs b/voxygen/src/ui/widgets/tooltip.rs index e491523b43..1129678df1 100644 --- a/voxygen/src/ui/widgets/tooltip.rs +++ b/voxygen/src/ui/widgets/tooltip.rs @@ -90,6 +90,7 @@ impl TooltipManager { } } + // return true if visible #[allow(clippy::too_many_arguments)] // TODO: Pending review in #587 fn set_tooltip( &mut self, @@ -101,7 +102,7 @@ impl TooltipManager { image_dims: Option<(f64, f64)>, src_id: widget::Id, ui: &mut UiCell, - ) { + ) -> bool { let tooltip_id = self.tooltip_id; let mp_h = MOUSE_PAD_Y / self.logical_scale_factor; @@ -154,6 +155,7 @@ impl TooltipManager { }, _ => (), } + matches!(self.state, HoverState::Hovering(Hover(id, _)) if id == src_id) } } @@ -178,9 +180,9 @@ impl<'a, W: Widget> Tooltipped<'a, W> { self } - pub fn set(self, id: widget::Id, ui: &mut UiCell) -> W::Event { + pub fn set_ext(self, id: widget::Id, ui: &mut UiCell) -> (W::Event, bool) { let event = self.inner.set(id, ui); - self.tooltip_manager.set_tooltip( + let visible = self.tooltip_manager.set_tooltip( self.tooltip, self.title_text, self.desc_text, @@ -190,8 +192,10 @@ impl<'a, W: Widget> Tooltipped<'a, W> { id, ui, ); - event + (event, visible) } + + pub fn set(self, id: widget::Id, ui: &mut UiCell) -> W::Event { self.set_ext(id, ui).0 } } pub trait Tooltipable { diff --git a/world/Cargo.toml b/world/Cargo.toml index 316e46ee25..9efb2939fb 100644 --- a/world/Cargo.toml +++ b/world/Cargo.toml @@ -34,10 +34,11 @@ packed_simd = { version = "0.3.4", package = "packed_simd_2" } rayon = "1.5" serde = { version = "1.0.110", features = ["derive"] } ron = { version = "0.6", default-features = false } +assets_manager = {version = "0.4.3", features = ["ron"]} [dev-dependencies] criterion = "0.3" -tracing-subscriber = { version = "0.2.3", default-features = false, features = ["fmt", "chrono", "ansi", "smallvec"] } +tracing-subscriber = { version = "0.2.15", default-features = false, features = ["fmt", "chrono", "ansi", "smallvec", "env-filter"] } minifb = "0.19.1" svg_fmt = "0.4" structopt = "0.3" diff --git a/world/economy_testinput.ron b/world/economy_testinput.ron new file mode 100644 index 0000000000..83f6cc62e9 --- /dev/null +++ b/world/economy_testinput.ron @@ -0,0 +1,220 @@ +[ + ( + name: "Credge", + position: (4176, 4080), + kind: Settlement, + neighbors: [ + (2, 112), + (1, 146), + (6, 98), + (9, 145), + ], + resources: [ + ( + good: Terrain(Lake), + amount: 4, + ), + ( + good: Terrain(Mountain), + amount: 52, + ), + ( + good: Terrain(Grassland), + amount: 7073, + ), + ( + good: Terrain(Ocean), + amount: 4929, + ), + ( + good: Terrain(Forest), + amount: 6360, + ), + ], + ), + ( + name: "Etodren", + position: (1200, 2928), + kind: Settlement, + neighbors: [ + (2, 258), + (0, 146), + (6, 60), + (9, 158), + ], + resources: [ + ( + good: Terrain(Mountain), + amount: 288, + ), + ( + good: Terrain(Grassland), + amount: 4129, + ), + ( + good: Terrain(Desert), + amount: 230, + ), + ( + good: Terrain(Ocean), + amount: 2923, + ), + ( + good: Terrain(Forest), + amount: 3139, + ), + ], + ), + ( + name: "Twige", + position: (2000, 7632), + kind: Settlement, + neighbors: [ + (0, 112), + (1, 258), + (6, 109), + (9, 32), + ], + resources: [ + ( + good: Terrain(Lake), + amount: 1, + ), + ( + good: Terrain(Mountain), + amount: 231, + ), + ( + good: Terrain(Grassland), + amount: 3308, + ), + ( + good: Terrain(Desert), + amount: 1695, + ), + ( + good: Terrain(Ocean), + amount: 487, + ), + ( + good: Terrain(Forest), + amount: 1338, + ), + ], + ), + ( + name: "Pleed Dungeon", + position: (6922, 1034), + kind: Dungeon, + neighbors: [], + resources: [], + ), + ( + name: "Fred Lair", + position: (3786, 2250), + kind: Dungeon, + neighbors: [], + resources: [], + ), + ( + name: "Frer Dungeon", + position: (6602, 2250), + kind: Dungeon, + neighbors: [], + resources: [], + ), + ( + name: "Inige Castle", + position: (1360, 4304), + kind: Castle, + neighbors: [ + (0, 98), + (1, 60), + (2, 109), + (9, 99), + ], + resources: [ + ( + good: Terrain(Mountain), + amount: 424, + ), + ( + good: Terrain(Grassland), + amount: 958, + ), + ( + good: Terrain(Desert), + amount: 1285, + ), + ( + good: Terrain(Ocean), + amount: 669, + ), + ( + good: Terrain(Forest), + amount: 1781, + ), + ], + ), + ( + name: "Estedock Catacombs", + position: (4650, 330), + kind: Dungeon, + neighbors: [], + resources: [], + ), + ( + name: "Oreefey Lair", + position: (1578, 3754), + kind: Dungeon, + neighbors: [], + resources: [], + ), + ( + name: "Lasnast Keep", + position: (1136, 6832), + kind: Castle, + neighbors: [ + (0, 145), + (2, 32), + (1, 158), + (6, 99), + ], + resources: [ + ( + good: Terrain(Mountain), + amount: 352, + ), + ( + good: Terrain(Grassland), + amount: 887, + ), + ( + good: Terrain(Desert), + amount: 2606, + ), + ( + good: Terrain(Ocean), + amount: 286, + ), + ( + good: Terrain(Forest), + amount: 679, + ), + ], + ), + ( + name: "Oren Lair", + position: (6730, 2506), + kind: Dungeon, + neighbors: [], + resources: [], + ), + ( + name: "Ween Crib", + position: (2250, 8010), + kind: Dungeon, + neighbors: [], + resources: [], + ), +] \ No newline at end of file diff --git a/world/examples/water.rs b/world/examples/water.rs index 0188148d6b..e4243b7cb1 100644 --- a/world/examples/water.rs +++ b/world/examples/water.rs @@ -8,7 +8,10 @@ use common::{ use rayon::prelude::*; use std::{f64, io::Write, path::PathBuf, time::SystemTime}; use tracing::{warn, Level}; -use tracing_subscriber::{filter::LevelFilter, EnvFilter, FmtSubscriber}; +use tracing_subscriber::{ + filter::{EnvFilter, LevelFilter}, + FmtSubscriber, +}; use vek::*; use veloren_world::{ sim::{self, get_horizon_map, sample_pos, sample_wpos, WorldOpts}, diff --git a/world/src/civ/mod.rs b/world/src/civ/mod.rs index 628d1536c9..bf481901d2 100644 --- a/world/src/civ/mod.rs +++ b/world/src/civ/mod.rs @@ -2,13 +2,12 @@ mod econ; -use self::{Occupation::*, Stock::*}; use crate::{ config::CONFIG, sim::WorldSim, site::{namegen::NameGen, Castle, Dungeon, Settlement, Site as WorldSite, Tree}, site2, - util::{attempt, seed_expan, MapVec, CARDINALS, NEIGHBORS}, + util::{attempt, seed_expan, NEIGHBORS}, Index, Land, }; use common::{ @@ -16,16 +15,12 @@ use common::{ path::Path, spiral::Spiral2d, store::{Id, Store}, - terrain::{MapSizeLg, TerrainChunkSize}, + terrain::{uniform_idx_as_vec2, MapSizeLg, TerrainChunkSize}, vol::RectVolSize, }; -use core::{ - fmt, - hash::{BuildHasherDefault, Hash}, - ops::Range, -}; -use fxhash::{FxHasher32, FxHasher64}; -use hashbrown::{HashMap, HashSet}; +use core::{fmt, hash::BuildHasherDefault, ops::Range}; +use fxhash::FxHasher64; +use hashbrown::HashMap; use rand::prelude::*; use rand_chacha::ChaChaRng; use tracing::{debug, info, warn}; @@ -113,35 +108,17 @@ impl Civs { _ => (SiteKind::Dungeon, 0), }; let loc = find_site_loc(&mut ctx, None, size)?; - this.establish_site(&mut ctx.reseed(), loc, |place| Site { + Some(this.establish_site(&mut ctx.reseed(), loc, |place| Site { kind, center: loc, place, site_tmp: None, - - population: 0.0, - - stocks: Stocks::from_default(100.0), - surplus: Stocks::from_default(0.0), - values: Stocks::from_default(None), - - labors: MapVec::from_default(0.01), - yields: MapVec::from_default(1.0), - productivity: MapVec::from_default(1.0), - - last_exports: Stocks::from_default(0.0), - export_targets: Stocks::from_default(0.0), - //trade_states: Stocks::default(), - coin: 1000.0, - }) + })) }); } // Tick - const SIM_YEARS: usize = 1000; - for _ in 0..SIM_YEARS { - this.tick(&mut ctx, 1.0); - } + //=== old economy is gone // Flatten ground around sites for site in this.sites.values() { @@ -243,6 +220,52 @@ impl Civs { //this.display_info(); + // remember neighbor information in economy + for (s1, val) in this.track_map.iter() { + if let Some(index1) = this.sites.get(*s1).site_tmp { + for (s2, t) in val.iter() { + if let Some(index2) = this.sites.get(*s2).site_tmp { + if index.sites.get(index1).do_economic_simulation() + && index.sites.get(index2).do_economic_simulation() + { + let cost = this.tracks.get(*t).path.len(); + index + .sites + .get_mut(index1) + .economy + .add_neighbor(index2, cost); + index + .sites + .get_mut(index2) + .economy + .add_neighbor(index1, cost); + } + } + } + } + } + + // collect natural resources + let sites = &mut index.sites; + (0..ctx.sim.map_size_lg().chunks_len()) + .into_iter() + .for_each(|posi| { + let chpos = uniform_idx_as_vec2(ctx.sim.map_size_lg(), posi); + let wpos = chpos * TerrainChunkSize::RECT_SIZE.map(|e| e as i32); + let closest_site = (*sites) + .iter_mut() + .filter(|s| !matches!(s.1.kind, crate::site::SiteKind::Dungeon(_))) + .min_by_key(|(_id, s)| s.get_origin().distance_squared(wpos)); + if let Some((_id, s)) = closest_site { + let distance_squared = s.get_origin().distance_squared(wpos); + s.economy + .add_chunk(ctx.sim.get(chpos).unwrap(), distance_squared); + } + }); + sites + .iter_mut() + .for_each(|(_, s)| s.economy.cache_economy()); + this } @@ -405,27 +428,16 @@ impl Civs { fn birth_civ(&mut self, ctx: &mut GenCtx) -> Option> { let site = attempt(5, || { let loc = find_site_loc(ctx, None, 1)?; - self.establish_site(ctx, loc, |place| Site { + Some(self.establish_site(ctx, loc, |place| Site { kind: SiteKind::Settlement, site_tmp: None, center: loc, place, - - population: 24.0, - - stocks: Stocks::from_default(100.0), - surplus: Stocks::from_default(0.0), - values: Stocks::from_default(None), - - labors: MapVec::from_default(0.01), - yields: MapVec::from_default(1.0), - productivity: MapVec::from_default(1.0), - - last_exports: Stocks::from_default(0.0), - export_targets: Stocks::from_default(0.0), - //trade_states: Stocks::default(), - coin: 1000.0, - }) + /* most economic members have moved to site/Economy */ + /* last_exports: Stocks::from_default(0.0), + * export_targets: Stocks::from_default(0.0), + * //trade_states: Stocks::default(), */ + })) })?; let civ = self.civs.insert(Civ { @@ -438,60 +450,11 @@ impl Civs { fn establish_place( &mut self, - ctx: &mut GenCtx, + _ctx: &mut GenCtx, loc: Vec2, - area: Range, - ) -> Option> { - // We use this hasher (FxHasher64) because - // (1) we don't care about DDOS attacks (ruling out SipHash); - // (2) we care about determinism across computers (ruling out AAHash); - // (3) we have 8-byte keys (for which FxHash is fastest). - let mut dead = HashSet::with_hasher(BuildHasherDefault::::default()); - let mut alive = HashSet::with_hasher(BuildHasherDefault::::default()); - alive.insert(loc); - - // Fill the surrounding area - while let Some(cloc) = alive.iter().choose(&mut ctx.rng).copied() { - for dir in CARDINALS.iter() { - if site_in_dir(&ctx.sim, cloc, *dir) { - let rloc = cloc + *dir; - if !dead.contains(&rloc) - && ctx - .sim - .get(rloc) - .map(|c| c.place.is_none()) - .unwrap_or(false) - { - alive.insert(rloc); - } - } - } - alive.remove(&cloc); - dead.insert(cloc); - - if dead.len() + alive.len() >= area.end { - break; - } - } - // Make sure the place is large enough - if dead.len() + alive.len() <= area.start { - return None; - } - - let place = self.places.insert(Place { - center: loc, - nat_res: NaturalResources::default(), - }); - - // Write place to map - for cell in dead.union(&alive) { - if let Some(chunk) = ctx.sim.get_mut(*cell) { - chunk.place = Some(place); - self.places.get_mut(place).nat_res.include_chunk(ctx, *cell); - } - } - - Some(place) + _area: Range, + ) -> Id { + self.places.insert(Place { center: loc }) } fn establish_site( @@ -499,12 +462,12 @@ impl Civs { ctx: &mut GenCtx, loc: Vec2, site_fn: impl FnOnce(Id) -> Site, - ) -> Option> { + ) -> Id { const SITE_AREA: Range = 1..4; //64..256; let place = match ctx.sim.get(loc).and_then(|site| site.place) { Some(place) => place, - None => self.establish_place(ctx, loc, SITE_AREA)?, + None => self.establish_place(ctx, loc, SITE_AREA), }; let site = self.sites.insert(site_fn(place)); @@ -568,76 +531,7 @@ impl Civs { } } - Some(site) - } - - fn tick(&mut self, _ctx: &mut GenCtx, years: f32) { - for site in self.sites.values_mut() { - site.simulate(years, &self.places.get(site.place).nat_res); - } - - // Trade stocks - // let mut stocks = TRADE_STOCKS; - // stocks.shuffle(ctx.rng); // Give each stock a chance to be traded - // first for stock in stocks.iter().copied() { - // let mut sell_orders = self.sites - // .iter_ids() - // .map(|(id, site)| (id, { - // econ::SellOrder { - // quantity: - // site.export_targets[stock].max(0.0).min(site.stocks[stock]), - // price: - // site.trade_states[stock].sell_belief.choose_price(ctx) * 1.25, // - // Trade cost q_sold: 0.0, - // } - // })) - // .filter(|(_, order)| order.quantity > 0.0) - // .collect::>(); - - // let mut sites = self.sites - // .ids() - // .collect::>(); - // sites.shuffle(ctx.rng); // Give all sites a chance to buy first - // for site in sites { - // let (max_spend, max_price, max_import) = { - // let site = self.sites.get(site); - // let budget = site.coin * 0.5; - // let total_value = site.values.iter().map(|(_, v)| - // (*v).unwrap_or(0.0)).sum::(); ( - // 100000.0,//(site.values[stock].unwrap_or(0.1) / - // total_value * budget).min(budget), - // site.trade_states[stock].buy_belief.price, - // -site.export_targets[stock].min(0.0), ) - // }; - // let (quantity, spent) = econ::buy_units(ctx, sell_orders - // .iter_mut() - // .filter(|(id, _)| site != *id && self.track_between(site, - // *id).is_some()) .map(|(_, order)| order), - // max_import, - // 1000000.0, // Max price TODO - // max_spend, - // ); - // let mut site = self.sites.get_mut(site); - // site.coin -= spent; - // if quantity > 0.0 { - // site.stocks[stock] += quantity; - // site.last_exports[stock] = -quantity; - // site.trade_states[stock].buy_belief.update_buyer(years, - // spent / quantity); println!("Belief: {:?}", - // site.trade_states[stock].buy_belief); } - // } - - // for (site, order) in sell_orders { - // let mut site = self.sites.get_mut(site); - // site.coin += order.q_sold * order.price; - // if order.q_sold > 0.0 { - // site.stocks[stock] -= order.q_sold; - // site.last_exports[stock] = order.q_sold; - // - // site.trade_states[stock].sell_belief.update_seller(order.q_sold / - // order.quantity); } - // } - // } + site } } @@ -782,45 +676,10 @@ pub struct Civ { #[derive(Debug)] pub struct Place { - center: Vec2, - nat_res: NaturalResources, -} - -// Productive capacity per year -#[derive(Default, Debug)] -pub struct NaturalResources { - wood: f32, - rock: f32, - river: f32, - farmland: f32, -} - -impl NaturalResources { - fn include_chunk(&mut self, ctx: &mut GenCtx, loc: Vec2) { - let chunk = if let Some(chunk) = ctx.sim.get(loc) { - chunk - } else { - return; - }; - - self.wood += chunk.tree_density; - self.rock += chunk.rockiness; - self.river += if chunk.river.is_river() { 5.0 } else { 0.0 }; - self.farmland += if chunk.humidity > 0.35 - && chunk.temp > -0.3 - && chunk.temp < 0.75 - && chunk.chaos < 0.5 - && ctx - .sim - .get_gradient_approx(loc) - .map(|grad| grad < 0.7) - .unwrap_or(false) - { - 1.0 - } else { - 0.0 - }; - } + pub center: Vec2, + /* act sort of like territory with sites belonging to it + * nat_res/NaturalResources was moved to Economy + * nat_res: NaturalResources, */ } pub struct Track { @@ -838,61 +697,11 @@ pub struct Site { pub site_tmp: Option>, pub center: Vec2, pub place: Id, - - population: f32, - - // Total amount of each stock - stocks: Stocks, - // Surplus stock compared to demand orders - surplus: Stocks, - // For some goods, such a goods without any supply, it doesn't make sense to talk about value - values: Stocks>, - - // Proportion of individuals dedicated to an industry - labors: MapVec, - // Per worker, per year, of their output good - yields: MapVec, - productivity: MapVec, - - last_exports: Stocks, - export_targets: Stocks, - //trade_states: Stocks, - coin: f32, } impl fmt::Display for Site { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { writeln!(f, "{:?}", self.kind)?; - writeln!(f, "- population: {}", self.population.floor() as u32)?; - writeln!(f, "- coin: {}", self.coin.floor() as u32)?; - writeln!(f, "Stocks")?; - for (stock, q) in self.stocks.iter() { - writeln!(f, "- {:?}: {}", stock, q.floor())?; - } - writeln!(f, "Values")?; - for stock in TRADE_STOCKS.iter() { - writeln!( - f, - "- {:?}: {}", - stock, - self.values[*stock] - .map(|x| x.to_string()) - .unwrap_or_else(|| "N/A".to_string()) - )?; - } - writeln!(f, "Laborers")?; - for (labor, n) in self.labors.iter() { - writeln!( - f, - "- {:?}: {}", - labor, - (*n * self.population).floor() as u32 - )?; - } - writeln!(f, "Export targets")?; - for (stock, n) in self.export_targets.iter() { - writeln!(f, "- {:?}: {}", stock, n)?; - } Ok(()) } @@ -908,236 +717,9 @@ pub enum SiteKind { } impl Site { - #[allow(clippy::let_and_return)] // TODO: Pending review in #587 - pub fn simulate(&mut self, years: f32, nat_res: &NaturalResources) { - // Insert natural resources into the economy - if self.stocks[Fish] < nat_res.river { - self.stocks[Fish] = nat_res.river; - } - if self.stocks[Wheat] < nat_res.farmland { - self.stocks[Wheat] = nat_res.farmland; - } - if self.stocks[Logs] < nat_res.wood { - self.stocks[Logs] = nat_res.wood; - } - if self.stocks[Game] < nat_res.wood { - self.stocks[Game] = nat_res.wood; - } - if self.stocks[Rock] < nat_res.rock { - self.stocks[Rock] = nat_res.rock; - } - - // We use this hasher (FxHasher32) because - // (1) we don't care about DDOS attacks (ruling out SipHash); - // (2) we care about determinism across computers (ruling out AAHash); - // (3) we have 1-byte keys (for which FxHash is supposedly fastest). - let orders = vec![ - (None, vec![(Food, 0.5)]), - (Some(Cook), vec![(Flour, 16.0), (Meat, 4.0), (Wood, 3.0)]), - (Some(Lumberjack), vec![(Logs, 4.5)]), - (Some(Miner), vec![(Rock, 7.5)]), - (Some(Fisher), vec![(Fish, 4.0)]), - (Some(Hunter), vec![(Game, 4.0)]), - (Some(Farmer), vec![(Wheat, 4.0)]), - ] - .into_iter() - .collect::, BuildHasherDefault>>(); - - // Per labourer, per year - let production = MapVec::from_list( - &[ - (Farmer, (Flour, 2.0)), - (Lumberjack, (Wood, 1.5)), - (Miner, (Stone, 0.6)), - (Fisher, (Meat, 3.0)), - (Hunter, (Meat, 0.25)), - (Cook, (Food, 20.0)), - ], - (Rock, 0.0), - ); - - let mut demand = Stocks::from_default(0.0); - for (labor, orders) in &orders { - let scale = if let Some(labor) = labor { - self.labors[*labor] - } else { - 1.0 - } * self.population; - for (stock, amount) in orders { - demand[*stock] += *amount * scale; - } - } - - let mut supply = Stocks::from_default(0.0); - for (labor, (output_stock, _)) in production.iter() { - supply[*output_stock] += self.yields[labor] * self.labors[labor] * self.population; - } - - let last_exports = &self.last_exports; - let stocks = &self.stocks; - self.surplus = demand - .clone() - .map(|stock, _| supply[stock] + stocks[stock] - demand[stock] - last_exports[stock]); - - // Update values according to the surplus of each stock - let values = &mut self.values; - self.surplus.iter().for_each(|(stock, surplus)| { - let val = 3.5f32.powf(1.0 - *surplus / demand[stock]); - values[stock] = if val > 0.001 && val < 1000.0 { - Some(val) - } else { - None - }; - }); - - // Update export targets based on relative values - let value_avg = values - .iter() - .map(|(_, v)| (*v).unwrap_or(0.0)) - .sum::() - .max(0.01) - / values.iter().filter(|(_, v)| v.is_some()).count() as f32; - let export_targets = &mut self.export_targets; - let last_exports = &self.last_exports; - self.values.iter().for_each(|(stock, value)| { - let rvalue = (*value).map(|v| v - value_avg).unwrap_or(0.0); - //let factor = if export_targets[stock] > 0.0 { 1.0 / rvalue } else { rvalue }; - export_targets[stock] = last_exports[stock] - rvalue * 0.1; // + (trade_states[stock].sell_belief.price - trade_states[stock].buy_belief.price) * 0.025; - }); - - let population = self.population; - - // Redistribute workforce according to relative good values - let labor_ratios = production.clone().map(|labor, (output_stock, _)| { - self.productivity[labor] * demand[output_stock] / supply[output_stock].max(0.001) - }); - let labor_ratio_sum = labor_ratios.iter().map(|(_, r)| *r).sum::().max(0.01); - production.iter().for_each(|(labor, _)| { - let smooth = 0.8; - self.labors[labor] = smooth * self.labors[labor] - + (1.0 - smooth) - * (labor_ratios[labor].max(labor_ratio_sum / 1000.0) / labor_ratio_sum); - }); - - // Production - let stocks_before = self.stocks.clone(); - for (labor, orders) in orders.iter() { - let scale = if let Some(labor) = labor { - self.labors[*labor] - } else { - 1.0 - } * population; - - // For each order, we try to find the minimum satisfaction rate - this limits - // how much we can produce! For example, if we need 0.25 fish and - // 0.75 oats to make 1 unit of food, but only 0.5 units of oats are - // available then we only need to consume 2/3rds - // of other ingredients and leave the rest in stock - // In effect, this is the productivity - let productivity = orders - .iter() - .map(|(stock, amount)| { - // What quantity is this order requesting? - let _quantity = *amount * scale; - // What proportion of this order is the economy able to satisfy? - let satisfaction = (stocks_before[*stock] / demand[*stock]).min(1.0); - satisfaction - }) - .min_by(|a, b| a.partial_cmp(b).unwrap()) - .unwrap_or_else(|| { - panic!("Industry {:?} requires at least one input order", labor) - }); - - for (stock, amount) in orders { - // What quantity is this order requesting? - let quantity = *amount * scale; - // What amount gets actually used in production? - let used = quantity * productivity; - - // Deplete stocks accordingly - self.stocks[*stock] = (self.stocks[*stock] - used).max(0.0); - } - - // Industries produce things - if let Some(labor) = labor { - let (stock, rate) = production[*labor]; - let workers = self.labors[*labor] * population; - let final_rate = rate; - let yield_per_worker = productivity * final_rate; - self.yields[*labor] = yield_per_worker; - self.productivity[*labor] = productivity; - self.stocks[stock] += yield_per_worker * workers.powf(1.1); - } - } - - // Denature stocks - self.stocks.iter_mut().for_each(|(_, v)| *v *= 0.9); - - // Births/deaths - const NATURAL_BIRTH_RATE: f32 = 0.15; - const DEATH_RATE: f32 = 0.05; - let birth_rate = if self.surplus[Food] > 0.0 { - NATURAL_BIRTH_RATE - } else { - 0.0 - }; - self.population += years * self.population * (birth_rate - DEATH_RATE); - } - pub fn is_dungeon(&self) -> bool { matches!(self.kind, SiteKind::Dungeon) } pub fn is_settlement(&self) -> bool { matches!(self.kind, SiteKind::Settlement) } pub fn is_castle(&self) -> bool { matches!(self.kind, SiteKind::Castle) } } - -#[repr(u8)] -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -enum Occupation { - Farmer = 0, - Lumberjack = 1, - Miner = 2, - Fisher = 3, - Hunter = 4, - Cook = 5, -} - -#[repr(u8)] -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub enum Stock { - Wheat = 0, - Flour = 1, - Meat = 2, - Fish = 3, - Game = 4, - Food = 5, - Logs = 6, - Wood = 7, - Rock = 8, - Stone = 9, -} - -const TRADE_STOCKS: [Stock; 5] = [Flour, Meat, Food, Wood, Stone]; - -#[derive(Debug, Clone)] -struct TradeState { - buy_belief: econ::Belief, - sell_belief: econ::Belief, -} - -impl Default for TradeState { - fn default() -> Self { - Self { - buy_belief: econ::Belief { - price: 1.0, - confidence: 0.25, - }, - sell_belief: econ::Belief { - price: 1.0, - confidence: 0.25, - }, - } - } -} - -pub type Stocks = MapVec; diff --git a/world/src/index.rs b/world/src/index.rs index cf20aafda7..c88e0f8859 100644 --- a/world/src/index.rs +++ b/world/src/index.rs @@ -1,4 +1,7 @@ -use crate::{site::Site, Colors}; +use crate::{ + site::{economy::TradeInformation, Site}, + Colors, +}; use common::{ assets::{AssetExt, AssetHandle}, store::Store, @@ -14,6 +17,7 @@ pub struct Index { pub time: f32, pub noise: Noise, pub sites: Store, + pub trade: TradeInformation, colors: AssetHandle>, } @@ -59,6 +63,7 @@ impl Index { time: 0.0, noise: Noise::new(seed), sites: Store::default(), + trade: Default::default(), colors, } } diff --git a/world/src/lib.rs b/world/src/lib.rs index 0117d899a3..078caeffcf 100644 --- a/world/src/lib.rs +++ b/world/src/lib.rs @@ -107,6 +107,9 @@ impl World { } pub fn get_map_data(&self, index: IndexRef) -> WorldMapMsg { + // we need these numbers to create unique ids for cave ends + let num_sites = self.civs().sites().count() as u64; + let num_caves = self.civs().caves.values().count() as u64; WorldMapMsg { sites: self .civs() @@ -114,6 +117,7 @@ impl World { .iter() .map(|(_, site)| { world_msg::SiteInfo { + id: site.site_tmp.map(|i| i.id()).unwrap_or_default(), name: site.site_tmp.map(|id| index.sites[id].name().to_string()), // TODO: Probably unify these, at some point kind: match &site.kind { @@ -135,13 +139,15 @@ impl World { self.civs() .caves .iter() - .map(|(_, info)| { + .map(|(id, info)| { // separate the two locations, combine with name - std::iter::once((info.name.clone(), info.location.0)) - .chain(std::iter::once((info.name.clone(), info.location.1))) + std::iter::once((id.id()+num_sites, info.name.clone(), info.location.0)) + // unfortunately we have to introduce a fake id (as it gets stored in a map in the client) + .chain(std::iter::once((id.id()+num_sites+num_caves, info.name.clone(), info.location.1))) }) .flatten() // unwrap inner iteration - .map(|(name, pos)| world_msg::SiteInfo { + .map(|(id, name, pos)| world_msg::SiteInfo { + id, name: Some(name), kind: world_msg::SiteKind::Cave, wpos: pos, @@ -359,6 +365,7 @@ impl World { chunk_wpos2d, sample_get, &mut supplement, + site.id(), ) }); diff --git a/world/src/sim2/mod.rs b/world/src/sim2/mod.rs index 2216286ed2..e4d1f3ba02 100644 --- a/world/src/sim2/mod.rs +++ b/world/src/sim2/mod.rs @@ -1,14 +1,23 @@ use crate::{ sim::WorldSim, site::{ - economy::{Good, Labor}, - Site, + economy::{ + decay_rate, direct_use_goods, good_list, transportation_effort, Economy, Labor, + TradeDelivery, TradeOrder, + }, + Site, SiteKind, }, - util::MapVec, + util::{DHashMap, DHashSet, MapVec}, Index, }; -use common::store::Id; -use tracing::debug; +use common::{ + store::Id, + trade::{ + Good, + Good::{Coin, Transportation}, + }, +}; +use tracing::{debug, info}; const MONTH: f32 = 30.0; const YEAR: f32 = 12.0 * MONTH; @@ -16,22 +25,92 @@ const TICK_PERIOD: f32 = 3.0 * MONTH; // 3 months const HISTORY_DAYS: f32 = 500.0 * YEAR; // 500 years const GENERATE_CSV: bool = false; +const INTER_SITE_TRADE: bool = true; + +#[derive(Debug)] +struct EconStatistics { + pub count: u32, + pub sum: f32, + pub min: f32, + pub max: f32, +} + +impl Default for EconStatistics { + fn default() -> Self { + Self { + count: 0, + sum: 0.0, + min: 1e30, + max: 0.0, + } + } +} + +impl EconStatistics { + fn collect(&mut self, value: f32) { + self.count += 1; + self.sum += value; + if value > self.max { + self.max = value; + } + if value < self.min { + self.min = value; + } + } +} + +pub fn csv_entry(f: &mut std::fs::File, site: &Site) { + use std::io::Write; + write!( + *f, + "{}, {}, {}, {},", + site.name(), + site.get_origin().x, + site.get_origin().y, + site.economy.pop + ) + .unwrap(); + for g in good_list() { + write!(*f, "{:?},", site.economy.values[*g].unwrap_or(-1.0)).unwrap(); + } + for g in good_list() { + write!(f, "{:?},", site.economy.labor_values[*g].unwrap_or(-1.0)).unwrap(); + } + for g in good_list() { + write!(f, "{:?},", site.economy.stocks[*g]).unwrap(); + } + for g in good_list() { + write!(f, "{:?},", site.economy.marginal_surplus[*g]).unwrap(); + } + for l in Labor::list() { + write!(f, "{:?},", site.economy.labors[l] * site.economy.pop).unwrap(); + } + for l in Labor::list() { + write!(f, "{:?},", site.economy.productivity[l]).unwrap(); + } + for l in Labor::list() { + write!(f, "{:?},", site.economy.yields[l]).unwrap(); + } + writeln!(f).unwrap(); +} pub fn simulate(index: &mut Index, world: &mut WorldSim) { use std::io::Write; + // please not that GENERATE_CSV is off by default, so panicing is not harmful + // here let mut f = if GENERATE_CSV { let mut f = std::fs::File::create("economy.csv").unwrap(); - write!(f, "Population,").unwrap(); - for g in Good::list() { + write!(f, "Site,PosX,PosY,Population,").unwrap(); + for g in good_list() { write!(f, "{:?} Value,", g).unwrap(); } - for g in Good::list() { + for g in good_list() { write!(f, "{:?} LaborVal,", g).unwrap(); } - for g in Good::list() { + for g in good_list() { write!(f, "{:?} Stock,", g).unwrap(); } - for g in Good::list() { + for g in good_list() { write!(f, "{:?} Surplus,", g).unwrap(); } for l in Labor::list() { @@ -49,6 +128,7 @@ pub fn simulate(index: &mut Index, world: &mut WorldSim) { None }; + tracing::info!("economy simulation start"); for i in 0..(HISTORY_DAYS / TICK_PERIOD) as i32 { if (index.time / YEAR) as i32 % 50 == 0 && (index.time % YEAR) as i32 == 0 { debug!("Year {}", (index.time / YEAR) as i32); @@ -58,33 +138,83 @@ pub fn simulate(index: &mut Index, world: &mut WorldSim) { if let Some(f) = f.as_mut() { if i % 5 == 0 { - let site = index.sites.values().next().unwrap(); - write!(f, "{},", site.economy.pop).unwrap(); - for g in Good::list() { - write!(f, "{:?},", site.economy.values[*g].unwrap_or(-1.0)).unwrap(); - } - for g in Good::list() { - write!(f, "{:?},", site.economy.labor_values[*g].unwrap_or(-1.0)).unwrap(); - } - for g in Good::list() { - write!(f, "{:?},", site.economy.stocks[*g]).unwrap(); - } - for g in Good::list() { - write!(f, "{:?},", site.economy.marginal_surplus[*g]).unwrap(); - } - for l in Labor::list() { - write!(f, "{:?},", site.economy.labors[*l] * site.economy.pop).unwrap(); - } - for l in Labor::list() { - write!(f, "{:?},", site.economy.productivity[*l]).unwrap(); - } - for l in Labor::list() { - write!(f, "{:?},", site.economy.yields[*l]).unwrap(); - } - writeln!(f).unwrap(); + let site = index + .sites + .values() + .find(|s| !matches!(s.kind, SiteKind::Dungeon(_))) + //match s.kind { + // SiteKind::Dungeon(_) => false, + // _ => true, + // }) + //.skip(3) // because first three get depopulated over time + .unwrap(); + csv_entry(f, site); } } } + tracing::info!("economy simulation end"); + + if let Some(f) = f.as_mut() { + writeln!(f).unwrap(); + for site in index.sites.ids() { + let site = index.sites.get(site); + csv_entry(f, site); + } + } + + { + let mut castles = EconStatistics::default(); + let mut towns = EconStatistics::default(); + let mut dungeons = EconStatistics::default(); + for site in index.sites.ids() { + let site = &index.sites[site]; + match site.kind { + SiteKind::Dungeon(_) => dungeons.collect(site.economy.pop), + SiteKind::Settlement(_) => towns.collect(site.economy.pop), + SiteKind::Castle(_) => castles.collect(site.economy.pop), + SiteKind::Tree(_) => (), + SiteKind::Refactor(_) => (), + } + } + info!( + "Towns {:.0}-{:.0} avg {:.0} inhabitants", + towns.min, + towns.max, + towns.sum / (towns.count as f32) + ); + info!( + "Castles {:.0}-{:.0} avg {:.0}", + castles.min, + castles.max, + castles.sum / (castles.count as f32) + ); + info!( + "Dungeons {:.0}-{:.0} avg {:.0}", + dungeons.min, + dungeons.max, + dungeons.sum / (dungeons.count as f32) + ); + check_money(index); + } +} + +fn check_money(index: &mut Index) { + let mut sum_stock: f32 = 0.0; + for site in index.sites.values() { + sum_stock += site.economy.stocks[Coin]; + } + let mut sum_del: f32 = 0.0; + for v in index.trade.deliveries.values() { + for del in v.iter() { + sum_del += del.amount[Coin]; + } + } + info!( + "Coin amount {} + {} = {}", + sum_stock, + sum_del, + sum_stock + sum_del + ); } pub fn tick(index: &mut Index, _world: &mut WorldSim, dt: f32) { @@ -92,10 +222,452 @@ pub fn tick(index: &mut Index, _world: &mut WorldSim, dt: f32) { for site in site_ids { tick_site_economy(index, site, dt); } + if INTER_SITE_TRADE { + for (&site, orders) in index.trade.orders.iter_mut() { + let siteinfo = index.sites.get_mut(site); + if siteinfo.do_economic_simulation() { + trade_at_site( + site, + orders, + &mut siteinfo.economy, + &mut index.trade.deliveries, + ); + } + } + } + //check_money(index); index.time += dt; } +/// plan the trading according to missing goods and prices at neighboring sites +/// (1st step of trading) +// returns wares spent (-) and procured (+) +// potential_trade: positive = buy, (negative = sell, unused) +fn plan_trade_for_site( + site: &mut Site, + site_id: &Id, + transportation_capacity: f32, + external_orders: &mut DHashMap, Vec>, + potential_trade: &mut MapVec, +) -> MapVec { + // TODO: Do we have some latency of information here (using last years + // capacity?) + //let total_transport_capacity = site.economy.stocks[Transportation]; + // TODO: We don't count the capacity per site, but globally (so there might be + // some imbalance in dispatch vs collection across sites (e.g. more dispatch + // than collection at one while more collection than dispatch at another)) + // transport capacity works both ways (going there and returning) + let mut dispatch_capacity = transportation_capacity; + let mut collect_capacity = transportation_capacity; + let mut missing_dispatch: f32 = 0.0; + let mut missing_collect: f32 = 0.0; + let mut result = MapVec::from_default(0.0); + const MIN_SELL_PRICE: f32 = 1.0; + // value+amount per good + let mut missing_goods: Vec<(Good, (f32, f32))> = site + .economy + .surplus + .iter() + .filter(|(g, a)| (**a < 0.0 && *g != Transportation)) + .map(|(g, a)| { + ( + g, + ( + site.economy.values[g].unwrap_or(Economy::MINIMUM_PRICE), + -*a, + ), + ) + }) + .collect(); + missing_goods.sort_by(|a, b| b.1.0.partial_cmp(&a.1.0).unwrap()); + let mut extra_goods: MapVec = MapVec::from_iter( + site.economy + .surplus + .iter() + .chain(core::iter::once((Coin, &site.economy.stocks[Coin]))) + .filter(|(g, a)| (**a > 0.0 && *g != Transportation)) + .map(|(g, a)| (g, *a)), + 0.0, + ); + // ratio+price per good and site + type GoodRatioPrice = Vec<(Good, (f32, f32))>; + let good_payment: DHashMap, GoodRatioPrice> = site + .economy + .neighbors + .iter() + .map(|n| { + let mut rel_value = extra_goods + .iter() + .map(|(g, _)| (g, n.last_values[g])) + .filter(|(_, last_val)| *last_val >= MIN_SELL_PRICE) + .map(|(g, last_val)| { + ( + g, + ( + last_val + / site.economy.values[g] + .unwrap_or(-1.0) + .max(Economy::MINIMUM_PRICE), + last_val, + ), + ) + }) + .collect::>(); + rel_value.sort_by(|a, b| (b.1.0.partial_cmp(&a.1.0).unwrap())); + (n.id, rel_value) + }) + .collect(); + // price+stock per site and good + type SitePriceStock = Vec<(Id, (f32, f32))>; + let mut good_price: DHashMap = missing_goods + .iter() + .map(|(g, _)| { + (*g, { + let mut neighbor_prices: Vec<(Id, (f32, f32))> = site + .economy + .neighbors + .iter() + .filter(|n| n.last_supplies[*g] > 0.0) + .map(|n| (n.id, (n.last_values[*g], n.last_supplies[*g]))) + .collect(); + neighbor_prices.sort_by(|a, b| a.1.0.partial_cmp(&b.1.0).unwrap()); + neighbor_prices + }) + }) + .collect(); + // TODO: we need to introduce priority (according to available transportation + // capacity) + let mut neighbor_orders: DHashMap, MapVec> = site + .economy + .neighbors + .iter() + .map(|n| (n.id, MapVec::default())) + .collect(); + if site_id.id() == 1 { + // cut down number of lines printed + debug!( + "Site {} #neighbors {} Transport capacity {}", + site_id.id(), + site.economy.neighbors.len(), + transportation_capacity, + ); + debug!("missing {:#?} extra {:#?}", missing_goods, extra_goods,); + debug!("buy {:#?} pay {:#?}", good_price, good_payment); + } + // === the actual planning is here === + for (g, (_, a)) in missing_goods.iter() { + let mut amount = *a; + for (s, (price, supply)) in good_price.get_mut(g).unwrap().iter_mut() { + // how much to buy, limit by supply and transport budget + let mut buy_target = amount.min(*supply); + let effort = transportation_effort(*g); + let collect = buy_target * effort; + let mut potential_balance: f32 = 0.0; + if collect > collect_capacity && effort > 0.0 { + let transportable_amount = collect_capacity / effort; + let missing_trade = buy_target - transportable_amount; + potential_trade[*g] += missing_trade; + potential_balance += missing_trade * *price; + buy_target = transportable_amount; // (buy_target - missing_trade).max(0.0); // avoid negative buy target caused by numeric inaccuracies + missing_collect += collect - collect_capacity; + debug!( + "missing capacity {:?}/{:?} {:?}", + missing_trade, amount, potential_balance, + ); + amount = (amount - missing_trade).max(0.0); // you won't be able to transport it from elsewhere either, so don't count multiple times + } + let mut balance: f32 = *price * buy_target; + debug!( + "buy {:?} at {:?} amount {:?} balance {:?}", + *g, + s.id(), + buy_target, + balance, + ); + // find suitable goods in exchange + let mut acute_missing_dispatch: f32 = 0.0; // only count the highest priority (not multiple times) + for (g2, (_, price2)) in good_payment[s].iter() { + let mut amount2 = extra_goods[*g2]; + // good available for trading? + if amount2 > 0.0 { + amount2 = amount2.min(balance / price2); // pay until balance is even + let effort2 = transportation_effort(*g2); + let mut dispatch = amount2 * effort2; + // limit by separate transport budget (on way back) + if dispatch > dispatch_capacity && effort2 > 0.0 { + let transportable_amount = dispatch_capacity / effort2; + let missing_trade = amount2 - transportable_amount; + amount2 = transportable_amount; + if acute_missing_dispatch == 0.0 { + acute_missing_dispatch = missing_trade * effort2; + } + debug!( + "can't carry payment {:?} {:?} {:?}", + g2, dispatch, dispatch_capacity + ); + dispatch = dispatch_capacity; + } + + extra_goods[*g2] -= amount2; + debug!("pay {:?} {:?} = {:?}", g2, amount2, balance); + balance -= amount2 * price2; + neighbor_orders.get_mut(s).unwrap()[*g2] -= amount2; + dispatch_capacity = (dispatch_capacity - dispatch).max(0.0); + if balance == 0.0 { + break; + } + } + } + missing_dispatch += acute_missing_dispatch; + // adjust order if we are unable to pay for it + buy_target -= balance / *price; + buy_target = buy_target.min(amount); + collect_capacity = (collect_capacity - buy_target * effort).max(0.0); + neighbor_orders.get_mut(s).unwrap()[*g] += buy_target; + amount -= buy_target; + debug!( + "deal amount {:?} end_balance {:?} price {:?} left {:?}", + buy_target, balance, *price, amount + ); + } + } + // if site_id.id() == 1 { + // // cut down number of lines printed + // info!("orders {:#?}", neighbor_orders,); + // } + // TODO: Use planned orders and calculate value, stock etc. accordingly + for n in &site.economy.neighbors { + let orders = neighbor_orders.get(&n.id).unwrap(); + for (g, a) in orders.iter() { + result[g] += *a; + } + let to = TradeOrder { + customer: *site_id, + amount: orders.clone(), + }; + if let Some(o) = external_orders.get_mut(&n.id) { + // this is just to catch unbound growth (happened in development) + if o.len() < 100 { + o.push(to); + } else { + debug!("overflow {:?}", o); + } + } else { + external_orders.insert(n.id, vec![to]); + } + } + // return missing transport capacity + //missing_collect.max(missing_dispatch) + debug!( + "Tranportation {:?} {:?} {:?} {:?} {:?}", + transportation_capacity, + collect_capacity, + dispatch_capacity, + missing_collect, + missing_dispatch, + ); + result[Transportation] = -(transportation_capacity - collect_capacity.min(dispatch_capacity) + + missing_collect.max(missing_dispatch)); + if site_id.id() == 1 { + debug!("Trade {:?}", result); + } + result +} + +/// perform trade using neighboring orders (2nd step of trading) +fn trade_at_site( + site: Id, + orders: &mut Vec, + economy: &mut Economy, + deliveries: &mut DHashMap, Vec>, +) { + // make sure that at least this amount of stock remains available + // TODO: rework using economy.unconsumed_stock + + let internal_orders = economy.get_orders(); + let mut next_demand = MapVec::from_default(0.0); + for (labor, orders) in &internal_orders { + let workers = if let Some(labor) = labor { + economy.labors[*labor] + } else { + 1.0 + } * economy.pop; + for (good, amount) in orders { + next_demand[*good] += *amount * workers; + assert!(next_demand[*good] >= 0.0); + } + } + //info!("Trade {} {}", site.id(), orders.len()); + let mut total_orders: MapVec = MapVec::from_default(0.0); + for i in orders.iter() { + for (g, &a) in i.amount.iter().filter(|(_, a)| **a > 0.0) { + total_orders[g] += a; + } + } + let order_stock_ratio: MapVec> = MapVec::from_iter( + economy + .stocks + .iter() + .map(|(g, a)| (g, *a, next_demand[g])) + .filter(|(_, a, s)| *a > *s) + .map(|(g, a, s)| (g, Some(total_orders[g] / (a - s)))), + None, + ); + debug!("trade {} {:?}", site.id(), order_stock_ratio); + let prices = MapVec::from_iter( + economy + .values + .iter() + .map(|(g, o)| (g, o.unwrap_or(0.0).max(Economy::MINIMUM_PRICE))), + 0.0, + ); + for o in orders.drain(..) { + // amount, local value (sell low value, buy high value goods first (trading + // town's interest)) + let mut sorted_sell: Vec<(Good, f32, f32)> = o + .amount + .iter() + .filter(|(_, &a)| a > 0.0) + .map(|(g, a)| (g, *a, prices[g])) + .collect(); + sorted_sell.sort_by(|a, b| (a.2.partial_cmp(&b.2).unwrap())); + let mut sorted_buy: Vec<(Good, f32, f32)> = o + .amount + .iter() + .filter(|(_, &a)| a < 0.0) + .map(|(g, a)| (g, *a, prices[g])) + .collect(); + sorted_buy.sort_by(|a, b| (b.2.partial_cmp(&a.2).unwrap())); + debug!( + "with {} {:?} buy {:?}", + o.customer.id(), + sorted_sell, + sorted_buy + ); + let mut good_delivery = MapVec::from_default(0.0); + for (g, amount, price) in sorted_sell.iter() { + if order_stock_ratio[*g].is_none() { + continue; + } + let allocated_amount = *amount / order_stock_ratio[*g].unwrap().max(1.0); + let mut balance = allocated_amount * *price; + for (g2, avail, price2) in sorted_buy.iter_mut() { + let amount2 = (-*avail).min(balance / *price2); + assert!(amount2 >= 0.0); + economy.stocks[*g2] += amount2; + balance = (balance - amount2 * *price2).max(0.0); + *avail += amount2; // reduce (negative) brought stock + debug!("paid with {:?} {} {}", *g2, amount2, *price2); + if balance == 0.0 { + break; + } + } + let paid_amount = allocated_amount - balance / *price; + if paid_amount / allocated_amount < 0.95 { + debug!( + "Client {} is broke on {:?} : {} {} severity {}", + o.customer.id(), + *g, + paid_amount, + allocated_amount, + order_stock_ratio[*g].unwrap(), + ); + } else { + debug!("bought {:?} {} {}", *g, paid_amount, *price); + } + good_delivery[*g] += paid_amount; + if economy.stocks[*g] - paid_amount < 0.0 { + info!( + "BUG {:?} {:?} {} TO {:?} OSR {:?} ND {:?}", + economy.stocks[*g], + *g, + paid_amount, + total_orders[*g], + order_stock_ratio[*g].unwrap(), + next_demand[*g] + ); + } + assert!(economy.stocks[*g] - paid_amount >= 0.0); + economy.stocks[*g] -= paid_amount; + } + for (g, amount, _) in sorted_buy.drain(..) { + if amount < 0.0 { + debug!("shipping back unsold {} of {:?}", amount, g); + good_delivery[g] += -amount; + } + } + let delivery = TradeDelivery { + supplier: site, + prices: prices.clone(), + supply: MapVec::from_iter( + economy.stocks.iter().map(|(g, a)| { + (g, { + (a - next_demand[g] - total_orders[g]).max(0.0) + good_delivery[g] + }) + }), + 0.0, + ), + amount: good_delivery, + }; + debug!(?delivery); + if let Some(deliveries) = deliveries.get_mut(&o.customer) { + deliveries.push(delivery); + } else { + deliveries.insert(o.customer, vec![delivery]); + } + } + if !orders.is_empty() { + info!("non empty orders {:?}", orders); + orders.clear(); + } +} + +/// 3rd step of trading +fn collect_deliveries(site: &mut Site, deliveries: &mut Vec) { + // collect all the goods we shipped + let mut last_exports = MapVec::from_iter( + site.economy + .active_exports + .iter() + .filter(|(_g, a)| **a > 0.0) + .map(|(g, a)| (g, *a)), + 0.0, + ); + // TODO: properly rate benefits created by merchants (done below?) + for mut d in deliveries.drain(..) { + for i in d.amount.iter() { + last_exports[i.0] -= *i.1; + } + // remember price + if let Some(n) = site + .economy + .neighbors + .iter_mut() + .find(|n| n.id == d.supplier) + { + // remember (and consume) last values + std::mem::swap(&mut n.last_values, &mut d.prices); + std::mem::swap(&mut n.last_supplies, &mut d.supply); + // add items to stock + for (g, a) in d.amount.iter() { + if *a < 0.0 { + // likely rounding error, ignore + debug!("Unexpected delivery for {:?} {}", g, *a); + } else { + site.economy.stocks[g] += *a; + } + } + } + } + if !deliveries.is_empty() { + info!("non empty deliveries {:?}", deliveries); + deliveries.clear(); + } + std::mem::swap(&mut last_exports, &mut site.economy.last_exports); + //site.economy.active_exports.clear(); +} + /// Simulate a site's economy. This simulation is roughly equivalent to the /// Lange-Lerner model's solution to the socialist calculation problem. The /// simulation begins by assigning arbitrary values to each commodity and then @@ -113,28 +685,47 @@ pub fn tick(index: &mut Index, _world: &mut WorldSim, dt: f32) { /// dynamically react to environmental changes. If a product becomes available /// through a mechanism such as trade, an entire arm of the economy may /// materialise to take advantage of this. -pub fn tick_site_economy(index: &mut Index, site: Id, dt: f32) { - let site = &mut index.sites[site]; +pub fn tick_site_economy(index: &mut Index, site_id: Id, dt: f32) { + let site = &mut index.sites[site_id]; + if !site.do_economic_simulation() { + return; + } + + // collect goods from trading + if INTER_SITE_TRADE { + let deliveries = index.trade.deliveries.get_mut(&site_id); + if let Some(deliveries) = deliveries { + collect_deliveries(site, deliveries); + } + } let orders = site.economy.get_orders(); let productivity = site.economy.get_productivity(); let mut demand = MapVec::from_default(0.0); for (labor, orders) in &orders { - let scale = if let Some(labor) = labor { + let workers = if let Some(labor) = labor { site.economy.labors[*labor] } else { 1.0 } * site.economy.pop; for (good, amount) in orders { - demand[*good] += *amount * scale; + demand[*good] += *amount * workers; } } + // which labor is the merchant + let merchant_labor = productivity + .iter() + .find(|(_, v)| (**v).iter().any(|(g, _)| *g == Transportation)) + .map(|(l, _)| l); + let mut supply = site.economy.stocks.clone(); //MapVec::from_default(0.0); - for (labor, (output_good, _)) in productivity.iter() { - supply[*output_good] += - site.economy.yields[labor] * site.economy.labors[labor] * site.economy.pop; + for (labor, goodvec) in productivity.iter() { + for (output_good, _) in goodvec.iter() { + supply[*output_good] += + site.economy.yields[labor] * site.economy.labors[labor] * site.economy.pop; + } } let stocks = &site.economy.stocks; @@ -143,64 +734,116 @@ pub fn tick_site_economy(index: &mut Index, site: Id, dt: f32) { .map(|g, demand| supply[g] + stocks[g] - demand); site.economy.marginal_surplus = demand.clone().map(|g, demand| supply[g] - demand); + // plan trading with other sites + let mut external_orders = &mut index.trade.orders; + let mut potential_trade = MapVec::from_default(0.0); + // use last year's generated transportation for merchants (could we do better? + // this is in line with the other professions) + let transportation_capacity = site.economy.stocks[Transportation]; + let trade = if INTER_SITE_TRADE { + let trade = plan_trade_for_site( + site, + &site_id, + transportation_capacity, + &mut external_orders, + &mut potential_trade, + ); + site.economy.active_exports = MapVec::from_iter(trade.iter().map(|(g, a)| (g, -*a)), 0.0); // TODO: check for availability? + + // add the wares to sell to demand and the goods to buy to supply + for (g, a) in trade.iter() { + if *a > 0.0 { + supply[g] += *a; + assert!(supply[g] >= 0.0); + } else { + demand[g] -= *a; + assert!(demand[g] >= 0.0); + } + } + demand[Coin] += Economy::STARTING_COIN; // if we spend coin value increases + trade + } else { + MapVec::default() + }; + // Update values according to the surplus of each stock // Note that values are used for workforce allocation and are not the same thing // as price let values = &mut site.economy.values; - site.economy.surplus.iter().for_each(|(good, surplus)| { - // Value rationalisation - let val = 2.0f32.powf(1.0 - *surplus / demand[good]); - let smooth = 0.8; - values[good] = if val > 0.001 && val < 1000.0 { - Some(smooth * values[good].unwrap_or(val) + (1.0 - smooth) * val) + site.economy + .surplus + .iter() + .chain(std::iter::once(( + Coin, + &(site.economy.stocks[Coin] - demand[Coin]), + ))) + .for_each(|(good, surplus)| { + // Value rationalisation + let val = 2.0f32.powf(1.0 - *surplus / demand[good]); + let smooth = 0.8; + values[good] = if val > 0.001 && val < 1000.0 { + Some(smooth * values[good].unwrap_or(val) + (1.0 - smooth) * val) + } else { + None + }; + }); + + let all_trade_goods: DHashSet = trade + .iter() + .filter(|(_, a)| **a > 0.0) + .chain(potential_trade.iter()) + .map(|(g, _)| g) + .collect(); + let empty_goods: DHashSet = DHashSet::default(); + // TODO: Does avg/max/sum make most sense for labors creating more than one good + // summing favors merchants too much (as they will provide multiple + // goods, so we use max instead) + let labor_ratios: MapVec = productivity.clone().map(|labor, goodvec| { + let trade_boost = if Some(labor) == merchant_labor { + all_trade_goods.iter() } else { - None + empty_goods.iter() }; - }); - - // Update export targets based on relative values - // let value_avg = values - // .iter() - // .map(|(_, v)| (*v).unwrap_or(0.0)) - // .sum::() - // .max(0.01) - // / values.iter().filter(|(_, v)| v.is_some()).count() as f32; - //let export_targets = &mut site.economy.export_targets; - //let last_exports = &self.last_exports; - // site.economy.values.iter().for_each(|(stock, value)| { - // let rvalue = (*value).map(|v| v - value_avg).unwrap_or(0.0); - // //let factor = if export_targets[stock] > 0.0 { 1.0 / rvalue } else { - // rvalue }; //export_targets[stock] = last_exports[stock] - rvalue * - // 0.1; // + (trade_states[stock].sell_belief.price - - // trade_states[stock].buy_belief.price) * 0.025; }); - - //let pop = site.economy.pop; - - // Redistribute workforce according to relative good values - let labor_ratios = productivity.clone().map(|labor, (output_good, _)| { - site.economy.values[output_good].unwrap_or(0.0) + goodvec + .iter() + .map(|(g, _)| g) + .chain(trade_boost) + .map(|output_good| site.economy.values[*output_good].unwrap_or(0.0)) + .max_by(|a, b| a.abs().partial_cmp(&b.abs()).unwrap()) + .unwrap_or(0.0) * site.economy.productivity[labor] - //(site.economy.prices[output_good] - site.economy.material_costs[output_good]) * site.economy.yields[labor] - //* demand[output_good] / supply[output_good].max(0.001) }); + debug!(?labor_ratios); + let labor_ratio_sum = labor_ratios.iter().map(|(_, r)| *r).sum::().max(0.01); productivity.iter().for_each(|(labor, _)| { let smooth = 0.8; site.economy.labors[labor] = smooth * site.economy.labors[labor] + (1.0 - smooth) * (labor_ratios[labor].max(labor_ratio_sum / 1000.0) / labor_ratio_sum); + assert!(site.economy.labors[labor] >= 0.0); }); // Production let stocks_before = site.economy.stocks.clone(); + + let direct_use = direct_use_goods(); + // Handle the stocks you can't pile (decay) + for g in direct_use { + site.economy.stocks[*g] = 0.0; + } + let mut total_labor_values = MapVec::<_, f32>::default(); + // TODO: trade let mut total_outputs = MapVec::<_, f32>::default(); for (labor, orders) in orders.iter() { - let scale = if let Some(labor) = labor { + let workers = if let Some(labor) = labor { site.economy.labors[*labor] } else { 1.0 } * site.economy.pop; + assert!(workers >= 0.0); + let is_merchant = merchant_labor == *labor; // For each order, we try to find the minimum satisfaction rate - this limits // how much we can produce! For example, if we need 0.25 fish and @@ -212,17 +855,20 @@ pub fn tick_site_economy(index: &mut Index, site: Id, dt: f32) { .iter() .map(|(good, amount)| { // What quantity is this order requesting? - let _quantity = *amount * scale; + let _quantity = *amount * workers; + assert!(stocks_before[*good] >= 0.0); + assert!(demand[*good] >= 0.0); // What proportion of this order is the economy able to satisfy? (stocks_before[*good] / demand[*good]).min(1.0) }) .min_by(|a, b| a.partial_cmp(b).unwrap()) .unwrap_or_else(|| panic!("Industry {:?} requires at least one input order", labor)); + assert!(labor_productivity >= 0.0); let mut total_materials_cost = 0.0; for (good, amount) in orders { // What quantity is this order requesting? - let quantity = *amount * scale; + let quantity = *amount * workers; // What amount gets actually used in production? let used = quantity * labor_productivity; @@ -230,29 +876,95 @@ pub fn tick_site_economy(index: &mut Index, site: Id, dt: f32) { total_materials_cost += used * site.economy.labor_values[*good].unwrap_or(0.0); // Deplete stocks accordingly - site.economy.stocks[*good] = (site.economy.stocks[*good] - used).max(0.0); + if !direct_use.contains(good) { + site.economy.stocks[*good] = (site.economy.stocks[*good] - used).max(0.0); + } + } + let mut produced_goods: MapVec = MapVec::from_default(0.0); + if INTER_SITE_TRADE && is_merchant { + // TODO: replan for missing merchant productivity??? + for (g, a) in trade.iter() { + if !direct_use.contains(&g) { + if *a < 0.0 { + // take these goods to the road + if site.economy.stocks[g] + *a < 0.0 { + // we have a problem: Probably due to a shift in productivity we have + // less goods available than planned, + // so we would need to reduce the amount shipped + debug!("NEG STOCK {:?} {} {}", g, site.economy.stocks[g], *a); + let reduced_amount = site.economy.stocks[g]; + let planned_amount: f32 = external_orders + .iter() + .map(|i| { + i.1.iter() + .filter(|o| o.customer == site_id) + .map(|j| j.amount[g]) + .sum::() + }) + .sum(); + let scale = reduced_amount / planned_amount.abs(); + debug!("re-plan {} {} {}", reduced_amount, planned_amount, scale); + for k in external_orders.iter_mut() { + for l in k.1.iter_mut().filter(|o| o.customer == site_id) { + l.amount[g] *= scale; + } + } + site.economy.stocks[g] = 0.0; + } + // assert!(site.economy.stocks[g] + *a >= 0.0); + else { + site.economy.stocks[g] += *a; + } + } + total_materials_cost += (-*a) * site.economy.labor_values[g].unwrap_or(0.0); + } else { + // count on receiving these + produced_goods[g] += *a; + } + } + debug!( + "merchant {} {}: {:?} {} {:?}", + site_id.id(), + site.economy.pop, + produced_goods, + total_materials_cost, + trade + ); } // Industries produce things if let Some(labor) = labor { - let (stock, rate) = productivity[*labor]; - let workers = site.economy.labors[*labor] * site.economy.pop; - let final_rate = rate; - let yield_per_worker = - labor_productivity * final_rate * (1.0 + workers / 100.0).min(3.0); - site.economy.yields[*labor] = yield_per_worker; + let work_products = &productivity[*labor]; + //let workers = site.economy.labors[*labor] * site.economy.pop; + //let final_rate = rate; + //let yield_per_worker = labor_productivity; + site.economy.yields[*labor] = + labor_productivity * work_products.iter().map(|(_, r)| r).sum::(); site.economy.productivity[*labor] = labor_productivity; - let total_output = yield_per_worker * workers; - site.economy.stocks[stock] += total_output; + //let total_product_rate: f32 = work_products.iter().map(|(_, r)| *r).sum(); + for (stock, rate) in work_products { + let total_output = labor_productivity * *rate * workers; + assert!(total_output >= 0.0); + site.economy.stocks[*stock] += total_output; + produced_goods[*stock] += total_output; + } - // Materials cost per unit - site.economy.material_costs[stock] = total_materials_cost / total_output.max(0.001); - // Labor costs - let wages = 1.0; - let total_labor_cost = workers * wages; + let produced_amount: f32 = produced_goods.iter().map(|(_, a)| *a).sum(); + for (stock, amount) in produced_goods.iter() { + let cost_weight = amount / produced_amount.max(0.001); + // Materials cost per unit + // TODO: How to handle this reasonably for multiple producers (collect upper and + // lower term separately) + site.economy.material_costs[stock] = + total_materials_cost / amount.max(0.001) * cost_weight; + // Labor costs + let wages = 1.0; + let total_labor_cost = workers * wages; - total_labor_values[stock] += total_materials_cost + total_labor_cost; - total_outputs[stock] += total_output; + total_labor_values[stock] += + (total_materials_cost + total_labor_cost) * cost_weight; + total_outputs[stock] += amount; + } } } @@ -260,17 +972,18 @@ pub fn tick_site_economy(index: &mut Index, site: Id, dt: f32) { site.economy.labor_values = total_labor_values.map(|stock, tlv| { let total_output = total_outputs[stock]; if total_output > 0.01 { - Some(tlv / total_outputs[stock]) + Some(tlv / total_output) } else { None } }); - // Decay stocks + // Decay stocks (the ones which totally decay are handled later) site.economy .stocks .iter_mut() - .for_each(|(c, v)| *v *= 1.0 - c.decay_rate()); + .map(|(c, v)| (v, 1.0 - decay_rate(c))) + .for_each(|(v, factor)| *v *= factor); // Decay stocks site.economy.replenish(index.time); @@ -284,4 +997,189 @@ pub fn tick_site_economy(index: &mut Index, site: Id, dt: f32) { 0.0 }; site.economy.pop += dt / YEAR * site.economy.pop * (birth_rate - DEATH_RATE); + + // calculate the new unclaimed stock + //let next_orders = site.economy.get_orders(); + // orders are static + let mut next_demand = MapVec::from_default(0.0); + for (labor, orders) in orders.iter() { + let workers = if let Some(labor) = labor { + site.economy.labors[*labor] + } else { + 1.0 + } * site.economy.pop; + for (good, amount) in orders { + next_demand[*good] += *amount * workers; + assert!(next_demand[*good] >= 0.0); + } + } + site.economy.unconsumed_stock = MapVec::from_iter( + site.economy + .stocks + .iter() + .map(|(g, a)| (g, *a - next_demand[g])), + 0.0, + ); +} + +#[cfg(test)] +mod tests { + use crate::{ + sim, + util::{seed_expan, MapVec}, + }; + use common::trade::Good; + use rand::SeedableRng; + use rand_chacha::ChaChaRng; + use serde::{Deserialize, Serialize}; + use tracing::{info, Level}; + use tracing_subscriber::{ + filter::{EnvFilter, LevelFilter}, + FmtSubscriber, + }; + use vek::Vec2; + + // enable info! + fn init() { + FmtSubscriber::builder() + .with_max_level(Level::ERROR) + .with_env_filter(EnvFilter::from_default_env().add_directive(LevelFilter::INFO.into())) + .init(); + } + + #[derive(Debug, Serialize, Deserialize)] + struct ResourcesSetup { + good: Good, + amount: f32, + } + + #[derive(Debug, Serialize, Deserialize)] + struct EconomySetup { + name: String, + position: (i32, i32), + kind: common::terrain::site::SitesKind, + neighbors: Vec<(u64, usize)>, // id, travel_distance + resources: Vec, + } + + #[test] + fn test_economy() { + init(); + info!("init"); + let seed = 59686; + let opts = sim::WorldOpts { + seed_elements: true, + world_file: sim::FileOpts::LoadAsset(sim::DEFAULT_WORLD_MAP.into()), + //sim::FileOpts::LoadAsset("world.map.economy_8x8".into()), + }; + let mut index = crate::index::Index::new(seed); + info!("Index created"); + let mut sim = sim::WorldSim::generate(seed, opts); + info!("World loaded"); + let regenerate_input = false; + if regenerate_input { + let _civs = crate::civ::Civs::generate(seed, &mut sim, &mut index); + info!("Civs created"); + let mut outarr: Vec = Vec::new(); + for i in index.sites.values() { + let resources: Vec = i + .economy + .natural_resources + .chunks_per_resource + .iter() + .map(|(good, a)| ResourcesSetup { + good, + amount: (*a as f32) + * i.economy.natural_resources.average_yield_per_chunk[good], + }) + .collect(); + let neighbors = i + .economy + .neighbors + .iter() + .map(|j| (j.id.id(), j.travel_distance)) + .collect(); + let val = EconomySetup { + name: i.name().into(), + position: (i.get_origin().x, i.get_origin().y), + resources, + neighbors, + kind: match i.kind { + crate::site::SiteKind::Settlement(_) => { + common::terrain::site::SitesKind::Settlement + }, + crate::site::SiteKind::Dungeon(_) => { + common::terrain::site::SitesKind::Dungeon + }, + crate::site::SiteKind::Castle(_) => { + common::terrain::site::SitesKind::Castle + }, + _ => common::terrain::site::SitesKind::Void, + }, + }; + outarr.push(val); + } + let pretty = ron::ser::PrettyConfig::new(); + let result = ron::ser::to_string_pretty(&outarr, pretty).unwrap(); + info!("RON {}", result); + } else { + let mut rng = ChaChaRng::from_seed(seed_expan::rng_state(seed)); + let ron_file = std::fs::File::open("economy_testinput.ron") + .expect("economy_testinput.ron not found"); + let econ_testinput: Vec = + ron::de::from_reader(ron_file).expect("economy_testinput.ron parse error"); + for i in econ_testinput.iter() { + let wpos = Vec2 { + x: i.position.0, + y: i.position.1, + }; + // this should be a moderate compromise between regenerating the full world and + // loading on demand using the public API. There is no way to set + // the name, do we care? + let mut settlement = match i.kind { + common::terrain::site::SitesKind::Castle => crate::site::Site::castle( + crate::site::Castle::generate(wpos, None, &mut rng), + ), + common::terrain::site::SitesKind::Dungeon => crate::site::Site::dungeon( + crate::site::Dungeon::generate(wpos, None, &mut rng), + ), + // common::terrain::site::SitesKind::Settlement | + _ => crate::site::Site::settlement(crate::site::Settlement::generate( + wpos, None, &mut rng, + )), + }; + for g in i.resources.iter() { + //let c = sim::SimChunk::new(); + //settlement.economy.add_chunk(ch, distance_squared) + // bypass the API for now + settlement.economy.natural_resources.chunks_per_resource[g.good] = + g.amount as u32; + settlement.economy.natural_resources.average_yield_per_chunk[g.good] = 1.0; + } + index.sites.insert(settlement); + } + // we can't add these in the first loop as neighbors will refer to later sites + // (which aren't valid in the first loop) + for (i, e) in econ_testinput.iter().enumerate() { + let id = index.sites.recreate_id(i as u64).unwrap(); + let mut neighbors: Vec = e + .neighbors + .iter() + .map(|(nid, dist)| crate::site::economy::NeighborInformation { + id: index.sites.recreate_id(*nid).unwrap(), + travel_distance: *dist, + last_values: MapVec::from_default(0.0), + last_supplies: MapVec::from_default(0.0), + }) + .collect(); + index + .sites + .get_mut(id) + .economy + .neighbors + .append(&mut neighbors); + } + } + crate::sim2::simulate(&mut index, &mut sim); + } } diff --git a/world/src/site/economy.gv b/world/src/site/economy.gv new file mode 100644 index 0000000000..9bf2987aa5 --- /dev/null +++ b/world/src/site/economy.gv @@ -0,0 +1,68 @@ +digraph economy { +Farmland [color="green"]; +Flour [color="orange"]; +Meat [color="orange"]; +Fish [color="green"]; +Game [color="green"]; +Food [color="orange"]; +Logs [color="green"]; +Wood [color="orange"]; +Rock [color="green"]; +Stone [color="orange"]; +Tools [color="orange"]; +Armor [color="orange"]; +Ingredients [color="green"]; +Potions [color="orange"]; +ControlledArea [color="green", shape=doubleoctagon]; + +// Professions +Everyone [shape=doubleoctagon]; +Farmer [shape=box]; +Lumberjack [shape=box]; +Miner [shape=box]; +Fisher [shape=box]; +Hunter [shape=box]; +Cook [shape=box]; +Brewer [shape=box]; +Blacksmith [shape=box]; +Bladesmith [shape=box]; +Guard [shape=box]; + +// Orders +Food -> Everyone [label= "0.5", style=dashed, weight=4]; +Flour -> Cook [label="12.0", penwidth=2.0]; +Meat -> Cook [label="4.0", penwidth=1.5]; +Wood -> Cook [label="1.5"]; +Stone -> Cook [label="1.0"]; +Logs -> Lumberjack [label="0.5", style=dashed, weight=4]; +Tools -> Lumberjack [label="0.1", color="orange", style=dashed]; +Rock -> Miner [label="0.5", style=dashed, weight=4]; +Tools -> Miner [label="0.1", color="orange", style=dashed]; +Fish -> Fisher [label="4.0", penwidth=1.5, weight=4]; +Tools -> Fisher [label="0.02", color="orange", style=dotted]; +Game -> Hunter [label="1.0", weight=4]; +Tools -> Hunter [label="0.1", color="orange", style=dashed]; +Farmland -> Farmer [label="2.0", weight=4]; +Tools -> Farmer [label="0.05", color="orange", style=dotted]; +Ingredients -> Brewer [label="2.0", penwidth=1.0]; +Flour -> Brewer [label="2.0", penwidth=1.0]; +Ingredients -> Blacksmith [label="8.0", penwidth=2.0]; +Wood -> Blacksmith [label="2.0", penwidth=1.0]; +Ingredients -> Bladesmith [label="4.0", penwidth=1.5]; +Wood -> Bladesmith [label="1.0", penwidth=1.0]; +Armor -> Guard [label="0.5", style=dashed]; +Tools -> Guard [label="0.3", color="orange", style=dashed]; +Potions -> Guard [label="3.0", penwidth=1.5]; + +// Products +Farmer -> Flour [label="2.0"]; +Lumberjack -> Wood [label="0.5", style=dashed]; +Miner -> Stone [label="0.5", style=dashed]; +Fisher -> Meat [label="4.0", penwidth=1.5]; +Hunter -> Meat [label="1.0"]; +Cook -> Food [label="16.0", penwidth=2.0]; +Blacksmith -> Armor [label="4.0"]; +Bladesmith -> Tools [label="2.0", color="orange"]; +Brewer -> Potions [label="6.0"]; +Guard -> ControlledArea [label="50.0", penwidth=2.0]; +} diff --git a/world/src/site/economy.rs b/world/src/site/economy.rs index 665ad52c1f..51f8fa1039 100644 --- a/world/src/site/economy.rs +++ b/world/src/site/economy.rs @@ -1,155 +1,401 @@ -use crate::util::{DHashMap, MapVec}; +use crate::{ + assets::{self, AssetExt, AssetHandle}, + sim::SimChunk, + site::Site, + util::{DHashMap, MapVec}, +}; +use common::{ + store::Id, + terrain::BiomeKind, + trade::{Good, SitePrices}, +}; +use lazy_static::lazy_static; +use serde::{Deserialize, Serialize}; +use std::{fmt, marker::PhantomData, sync::Once}; -#[repr(u8)] -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub enum Good { - Wheat = 0, - Flour = 1, - Meat = 2, - Fish = 3, - Game = 4, - Food = 5, - Logs = 6, - Wood = 7, - Rock = 8, - Stone = 9, -} use Good::*; -#[repr(u8)] -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub enum Labor { - Farmer = 0, - Lumberjack = 1, - Miner = 2, - Fisher = 3, - Hunter = 4, - Cook = 5, +#[derive(Debug, Serialize, Deserialize)] +pub struct Profession { + pub name: String, + pub orders: Vec<(Good, f32)>, + pub products: Vec<(Good, f32)>, } -use Labor::*; +// reference to profession +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub struct Labor(u8, PhantomData); + +#[derive(Debug)] +pub struct AreaResources { + pub resource_sum: MapVec, + pub resource_chunks: MapVec, + pub chunks: u32, +} + +impl Default for AreaResources { + fn default() -> Self { + Self { + resource_sum: MapVec::default(), + resource_chunks: MapVec::default(), + chunks: 0, + } + } +} + +#[derive(Debug)] +pub struct NaturalResources { + // resources per distance, we should increase labor cost for far resources + pub per_area: Vec, + + // computation simplifying cached values + pub chunks_per_resource: MapVec, + pub average_yield_per_chunk: MapVec, +} + +impl Default for NaturalResources { + fn default() -> Self { + Self { + per_area: Vec::new(), + chunks_per_resource: MapVec::default(), + average_yield_per_chunk: MapVec::default(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct RawProfessions(Vec); + +impl assets::Asset for RawProfessions { + type Loader = assets::RonLoader; + + const EXTENSION: &'static str = "ron"; +} + +pub fn default_professions() -> AssetHandle { + RawProfessions::load_expect("common.professions") +} + +lazy_static! { + static ref LABOR: AssetHandle = default_professions(); + // used to define resources needed by every person + static ref DUMMY_LABOR: Labor = Labor( + LABOR + .read() + .0 + .iter() + .position(|a| a.name == "_") + .unwrap_or(0) as u8, + PhantomData + ); +} + +impl fmt::Debug for Labor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if (self.0 as usize) < LABOR.read().0.len() { + f.write_str(&LABOR.read().0[self.0 as usize].name) + } else { + f.write_str("?") + } + } +} + +#[derive(Debug)] +pub struct TradeOrder { + pub customer: Id, + pub amount: MapVec, // positive for orders, negative for exchange +} + +#[derive(Debug)] +pub struct TradeDelivery { + pub supplier: Id, + pub amount: MapVec, // positive for orders, negative for exchange + pub prices: MapVec, // at the time of interaction + pub supply: MapVec, // maximum amount available, at the time of interaction +} + +#[derive(Debug)] +pub struct TradeInformation { + pub orders: DHashMap, Vec>, // per provider + pub deliveries: DHashMap, Vec>, // per receiver +} + +impl Default for TradeInformation { + fn default() -> Self { + Self { + orders: Default::default(), + deliveries: Default::default(), + } + } +} + +#[derive(Debug)] +pub struct NeighborInformation { + pub id: Id, + pub travel_distance: usize, + + // remembered from last interaction + pub last_values: MapVec, + pub last_supplies: MapVec, +} + +#[derive(Debug)] pub struct Economy { + // Population pub pop: f32, + /// Total available amount of each good pub stocks: MapVec, + /// Surplus stock compared to demand orders pub surplus: MapVec, + /// change rate (derivative) of stock in the current situation pub marginal_surplus: MapVec, + /// amount of wares not needed by the economy (helps with trade planning) + pub unconsumed_stock: MapVec, + // For some goods, such a goods without any supply, it doesn't make sense to talk about value pub values: MapVec>, - + pub last_exports: MapVec, + pub active_exports: MapVec, // unfinished trade (amount unconfirmed) + //pub export_targets: MapVec, pub labor_values: MapVec>, pub material_costs: MapVec, + // Proportion of individuals dedicated to an industry pub labors: MapVec, + // Per worker, per year, of their output good pub yields: MapVec, pub productivity: MapVec, + + pub natural_resources: NaturalResources, + // usize is distance + pub neighbors: Vec, } +static INIT: Once = Once::new(); + impl Default for Economy { fn default() -> Self { + INIT.call_once(|| { + LABOR.read(); + }); Self { pop: 32.0, - stocks: Default::default(), + stocks: MapVec::from_list(&[(Coin, Economy::STARTING_COIN)], 100.0), surplus: Default::default(), marginal_surplus: Default::default(), - values: Default::default(), + values: MapVec::from_list(&[(Coin, Some(2.0))], None), + last_exports: Default::default(), + active_exports: Default::default(), labor_values: Default::default(), material_costs: Default::default(), - labors: Default::default(), - yields: Default::default(), - productivity: Default::default(), + labors: MapVec::from_default(0.01), + yields: MapVec::from_default(1.0), + productivity: MapVec::from_default(1.0), + + natural_resources: Default::default(), + neighbors: Default::default(), + unconsumed_stock: Default::default(), } } } impl Economy { + pub const MINIMUM_PRICE: f32 = 0.1; + pub const STARTING_COIN: f32 = 1000.0; + const _NATURAL_RESOURCE_SCALE: f32 = 1.0 / 9.0; + + pub fn cache_economy(&mut self) { + for &g in good_list() { + let amount: f32 = self + .natural_resources + .per_area + .iter() + .map(|a| a.resource_sum[g]) + .sum(); + let chunks = self + .natural_resources + .per_area + .iter() + .map(|a| a.resource_chunks[g]) + .sum(); + if chunks != 0 { + self.natural_resources.chunks_per_resource[g] = chunks; + self.natural_resources.average_yield_per_chunk[g] = amount / (chunks as f32); + } + } + } + pub fn get_orders(&self) -> DHashMap, Vec<(Good, f32)>> { - vec![ - (None, vec![(Food, 0.5)]), - (Some(Cook), vec![ - (Flour, 12.0), - (Meat, 4.0), - (Wood, 1.5), - (Stone, 1.0), - ]), - (Some(Lumberjack), vec![(Logs, 0.5)]), - (Some(Miner), vec![(Rock, 0.5)]), - (Some(Fisher), vec![(Fish, 4.0)]), - (Some(Hunter), vec![(Game, 1.0)]), - (Some(Farmer), vec![(Wheat, 2.0)]), - ] - .into_iter() - .collect() + LABOR + .read() + .0 + .iter() + .enumerate() + .map(|(i, p)| { + ( + if p.name == "_" { + None + } else { + Some(Labor(i as u8, PhantomData)) + }, + p.orders.clone(), + ) + }) + .collect() } - pub fn get_productivity(&self) -> MapVec { - // Per labourer, per year - MapVec::from_list( - &[ - (Farmer, (Flour, 2.0)), - (Lumberjack, (Wood, 0.5)), - (Miner, (Stone, 0.5)), - (Fisher, (Meat, 4.0)), - (Hunter, (Meat, 1.0)), - (Cook, (Food, 16.0)), - ], - (Rock, 0.0), - ) - .map(|l, (good, v)| (good, v * (1.0 + self.labors[l]))) + pub fn get_productivity(&self) -> MapVec> { + let products: MapVec> = MapVec::from_iter( + LABOR + .read() + .0 + .iter() + .enumerate() + .filter(|(_, p)| !p.products.is_empty()) + .map(|(i, p)| (Labor(i as u8, PhantomData), p.products.clone())), + vec![(Good::Terrain(BiomeKind::Void), 0.0)], + ); + products.map(|l, vec| { + let labor_ratio = self.labors[l]; + let total_workers = labor_ratio * self.pop; + // apply economy of scale (workers get more productive in numbers) + let relative_scale = 1.0 + labor_ratio; + let absolute_scale = (1.0 + total_workers / 100.0).min(3.0); + let scale = relative_scale * absolute_scale; + vec.iter() + .map(|(good, amount)| (*good, amount * scale)) + .collect() + }) } - pub fn replenish(&mut self, time: f32) { - //use rand::Rng; - for (i, (g, v)) in [ - (Wheat, 50.0), - (Logs, 20.0), - (Rock, 120.0), - (Game, 12.0), - (Fish, 10.0), - ] - .iter() - .enumerate() - { - self.stocks[*g] = (*v - * (1.25 + (((time * 0.0001 + i as f32).sin() + 1.0) % 1.0) * 0.5) - - self.stocks[*g]) - * 0.075; //rand::thread_rng().gen_range(0.05, 0.1); + pub fn replenish(&mut self, _time: f32) { + for (good, &ch) in self.natural_resources.chunks_per_resource.iter() { + let per_year = self.natural_resources.average_yield_per_chunk[good] * (ch as f32); + self.stocks[good] = self.stocks[good].max(per_year); + } + // info!("resources {:?}", self.stocks); + } + + pub fn add_chunk(&mut self, ch: &SimChunk, distance_squared: i32) { + let biome = ch.get_biome(); + // we don't scale by pi, although that would be correct + let distance_bin = (distance_squared >> 16).min(64) as usize; + if self.natural_resources.per_area.len() <= distance_bin { + self.natural_resources + .per_area + .resize_with(distance_bin + 1, Default::default); + } + self.natural_resources.per_area[distance_bin].chunks += 1; + self.natural_resources.per_area[distance_bin].resource_sum[Terrain(biome)] += 1.0; + self.natural_resources.per_area[distance_bin].resource_chunks[Terrain(biome)] += 1; + // TODO: Scale resources by rockiness or tree_density? + } + + pub fn add_neighbor(&mut self, id: Id, distance: usize) { + self.neighbors.push(NeighborInformation { + id, + travel_distance: distance, + + last_values: MapVec::from_default(Economy::MINIMUM_PRICE), + last_supplies: Default::default(), + }); + } + + pub fn get_site_prices(&self) -> SitePrices { + SitePrices { + values: self + .values + .iter() + .map(|(g, v)| (g, v.unwrap_or(Economy::MINIMUM_PRICE))) + .collect(), } } } -impl Default for Good { - fn default() -> Self { - Good::Rock // Arbitrary +pub fn good_list() -> &'static [Good] { + static GOODS: [Good; 23] = [ + // controlled resources + Territory(BiomeKind::Grassland), + Territory(BiomeKind::Forest), + Territory(BiomeKind::Lake), + Territory(BiomeKind::Ocean), + Territory(BiomeKind::Mountain), + RoadSecurity, + Ingredients, + // produced goods + Flour, + Meat, + Wood, + Stone, + Food, + Tools, + Armor, + Potions, + Transportation, + // exchange currency + Coin, + // uncontrolled resources + Terrain(BiomeKind::Lake), + Terrain(BiomeKind::Mountain), + Terrain(BiomeKind::Grassland), + Terrain(BiomeKind::Forest), + Terrain(BiomeKind::Desert), + Terrain(BiomeKind::Ocean), + ]; + + &GOODS +} + +pub fn transportation_effort(g: Good) -> f32 { + match g { + Terrain(_) | Territory(_) | RoadSecurity => 0.0, + Coin => 0.01, + Potions => 0.1, + + Armor => 2.0, + Stone => 4.0, + + _ => 1.0, } } -impl Good { - pub fn list() -> &'static [Self] { - static GOODS: [Good; 10] = [ - Wheat, Flour, Meat, Fish, Game, Food, Logs, Wood, Rock, Stone, - ]; - - &GOODS +pub fn decay_rate(g: Good) -> f32 { + match g { + Food => 0.2, + Flour => 0.1, + Meat => 0.25, + Ingredients => 0.1, + _ => 0.0, } +} - pub fn decay_rate(&self) -> f32 { - match self { - Food => 0.2, - Wheat => 0.1, - Meat => 0.25, - Fish => 0.2, - _ => 0.0, - } - } +/** you can't accumulate or save these options/resources for later */ +pub fn direct_use_goods() -> &'static [Good] { + static DIRECT_USE: [Good; 13] = [ + Transportation, + Territory(BiomeKind::Grassland), + Territory(BiomeKind::Forest), + Territory(BiomeKind::Lake), + Territory(BiomeKind::Ocean), + Territory(BiomeKind::Mountain), + RoadSecurity, + Terrain(BiomeKind::Grassland), + Terrain(BiomeKind::Forest), + Terrain(BiomeKind::Lake), + Terrain(BiomeKind::Ocean), + Terrain(BiomeKind::Mountain), + Terrain(BiomeKind::Desert), + ]; + &DIRECT_USE } impl Labor { - pub fn list() -> &'static [Self] { - static LABORS: [Labor; 6] = [Farmer, Lumberjack, Miner, Fisher, Hunter, Cook]; - - &LABORS + pub fn list() -> impl Iterator { + (0..LABOR.read().0.len()) + .filter(|&i| i != (DUMMY_LABOR.0 as usize)) + .map(|i| Self(i as u8, PhantomData)) } } diff --git a/world/src/site/mod.rs b/world/src/site/mod.rs index 2f7ed2a7e3..37283b40a7 100644 --- a/world/src/site/mod.rs +++ b/world/src/site/mod.rs @@ -141,10 +141,20 @@ impl Site { wpos2d: Vec2, get_column: impl FnMut(Vec2) -> Option<&'a ColumnSample<'a>>, supplement: &mut ChunkSupplement, + site_id: common::trade::SiteId, ) { match &self.kind { SiteKind::Settlement(s) => { - s.apply_supplement(dynamic_rng, wpos2d, get_column, supplement) + let economy = common::trade::SiteInformation { + id: site_id, + unconsumed_stock: self + .economy + .unconsumed_stock + .iter() + .map(|(g, a)| (g, *a)) + .collect(), + }; + s.apply_supplement(dynamic_rng, wpos2d, get_column, supplement, economy) }, SiteKind::Dungeon(d) => d.apply_supplement(dynamic_rng, wpos2d, get_column, supplement), SiteKind::Castle(c) => c.apply_supplement(dynamic_rng, wpos2d, get_column, supplement), @@ -152,4 +162,6 @@ impl Site { SiteKind::Tree(_) => {}, } } + + pub fn do_economic_simulation(&self) -> bool { matches!(self.kind, SiteKind::Settlement(_)) } } diff --git a/world/src/site/settlement/mod.rs b/world/src/site/settlement/mod.rs index 42c314028b..4cd1b10e42 100644 --- a/world/src/site/settlement/mod.rs +++ b/world/src/site/settlement/mod.rs @@ -23,6 +23,7 @@ use common::{ spiral::Spiral2d, store::{Id, Store}, terrain::{Block, BlockKind, SpriteKind, TerrainChunkSize}, + trade::SiteInformation, vol::{BaseVol, ReadVol, RectSizedVol, RectVolSize, WriteVol}, }; use fxhash::FxHasher64; @@ -855,7 +856,21 @@ impl Settlement { wpos2d: Vec2, mut get_column: impl FnMut(Vec2) -> Option<&'a ColumnSample<'a>>, supplement: &mut ChunkSupplement, + economy: SiteInformation, ) { + // let economy: HashMap = SiteInformation::economy + // .values + // .iter() + // .map(|(g, v)| { + // ( + // g, + // ( + // v.unwrap_or(Economy::MINIMUM_PRICE), + // economy.stocks[g] + economy.surplus[g], + // ), + // ) + // }) + // .collect(); for y in 0..TerrainChunkSize::RECT_SIZE.y as i32 { for x in 0..TerrainChunkSize::RECT_SIZE.x as i32 { let offs = Vec2::new(x, y); @@ -938,6 +953,17 @@ impl Settlement { .with_skillset_config( common::skillset_builder::SkillSetConfig::Guard, ), + 1 => entity + .with_main_tool(Item::new_from_asset_expect( + "common.items.weapons.bow.eldwood-0", + )) + .with_name("Merchant") + .with_level(dynamic_rng.gen_range(10..15)) + .with_loadout_config(loadout_builder::LoadoutConfig::Merchant) + .with_skillset_config( + common::skillset_builder::SkillSetConfig::Merchant, + ) + .with_economy(&economy), _ => entity .with_main_tool(Item::new_from_asset_expect( match dynamic_rng.gen_range(0..7) { diff --git a/world/src/util/map_vec.rs b/world/src/util/map_vec.rs index 47efe67d0a..b204e6f8c4 100644 --- a/world/src/util/map_vec.rs +++ b/world/src/util/map_vec.rs @@ -1,6 +1,15 @@ use crate::util::DHashMap; use std::hash::Hash; +/** A static table of known values where any key always maps to a single value. + +It's not really intended to be a "collection" in the same way that, say, HashMap or Vec is +Since it's not intended to have a way of expressing that a value is not present (hence the default behaviour) +It's really quite specifically tailored to its application in the economy code where it wouldn't make sense to not have certain entries +Store is a bit different in that it is the one to generate an index, and so it can hold as many things as you like +Whereas with MapVec, we always know the index ahead of time. +**/ + #[derive(Clone, Debug)] pub struct MapVec { /// We use this hasher (FxHasher32) because @@ -33,6 +42,13 @@ impl MapVec { } } + pub fn from_iter(i: impl Iterator, default: T) -> Self { + Self { + entries: i.collect(), + default, + } + } + pub fn from_default(default: T) -> Self { Self { entries: DHashMap::default(),