From 15b11d9154484b17c81f1834a07726c9a966da8b Mon Sep 17 00:00:00 2001 From: Christof Petig Date: Sun, 14 Mar 2021 03:18:32 +0000 Subject: [PATCH] Implement /price_list (work in progress), stub for /buy and /sell remove outdated economic simulation code remove old values, document add natural resources to economy Remove NaturalResources from Place (now in Economy) find closest site to each chunk implement natural resources (the distance scale is wrong) cargo fmt working distance calculation this collection of natural resources seem to make sense, too much Wheat though use natural resources and controlled area to replenish goods increase the amount of chunks controlled by one guard to 50 add new professions and goods to the list implement multiple products per worker remove the old code and rename the new code to the previous name correctly determine which goods guards will give you access to correctly estimate the amount of natural resources controlled adapt to new server API instrument tooltips Now I just need to figure out how to store a (reference to) a closure closures for tooltip content generation pass site/cave id to the client Add economic information to the client structure (not yet exchanged with the server) Send SiteId to the client, prepare messages for economy request Make client::sites a HashMap Specialize the Crafter into Brewer,Bladesmith and Blacksmith working server request for economic info from within tooltip fully operational economic tooltips I need to fix the id clash between caves and towns though fix overlapping ids between caves and sites display stock amount correctly handle invalid (cave) ids in the request some initial balancing, turn off most info logging less intrusive way of implementing the dynamic tool tips in map further tooltip cleanup further cleanup, dynamic tooltip not fully working as intended correctly working tooltip visibility logic cleanup, display labor value separate economy info request in a separate translation unit display values as well nicer display format for economy add last_exports and coin to the new economy do not allocate natural resources to Dungeons (making town so much larger) balancing attempt print town size statistics cargo fmt (dead code) resource tweaks, csv debugging output a more interesting town (and then all sites) fix the labor value logic (now we have meaningful prices) load professions from ron (WIP) use assets manager in economy loading professions works use professions from ron file fix Labor debug logic count chunks per type separately (preparing for better resource control) better structured resource data traders, more professions (WIP) fix exception when starting the simulation fix replenish function TODO: - use area_ratio for resource usage (chunks should be added to stock, ratio on usage?) - fix trading documentation clean up fix merge artifact Revise trader mechanic start Coin with a reasonable default remove the outdated economy code preserve documentation from removed old structure output neighboring sites (preparation) pass list of neighbors to economy add trade structures trading stub Description of purpose by zesterer on Discord remember prices (needed for planning) avoid growing the order vector unboundedly into_iter doesn't clear the Vec, so clear it manually use drain to process Vecs, avoid clone fix the test server implement a test stub (I need to get it faster than 30 seconds to be more useful) enable info output in test debug missing and extra goods use the same logging extension as water, activate feature update dependencies determine good prices, good exchange goods a working set of revisions a cozy world which economy tests in 2s first order planning version fun with package version buy according to value/priority, not missing amount introduce min price constant, fix order priority in depth debugging with a correct sign the trading plans start to make sense move the trade planning to a separate function rename new function reorganize code into subroutines (much cleaner) each trading step now has its own function cut down the number of debugging output introduce RoadSecurity and Transportation transport capacity bookkeeping only plan to pay with valuable goods, you can no longer stockpile unused options (which interestingly shows a huge impact, to be investigated) Coin is now listed as a payment (although not used) proper transportation estimation (although 0) remove more left overs uncovered by viewing the code in a merge request use the old default values, handle non-pileable stocks directly before increasing it (as economy is based on last year's products) don't order the missing good multiple times also it uses coin to buy things! fix warnings and use the transportation from stock again cargo fmt prepare evaluation of trade don't count transportation multiple times fix merge artifact operational trade planning trade itself is still misleading make clippy happy clean up correct labor ratio of merchants (no need to multiply with amount produced) incomplete merchant labor_value computation correct last commit make economy of scale more explicit make clippy happy (and code cleaner) more merchant tweaks (more pop=better) beginning of real trading code revert the update of dependencies remove stale comments/unused code trading implementation complete (but untested) something is still strange ... fix sign in trading another sign fix some bugfixes and plenty of debugging code another bug fixed, more to go fix another invariant (rounding will lead to very small negative value) introduce Terrain and Territory fix merge mistakes --- CHANGELOG.md | 1 + Cargo.lock | 2 + assets/common/item_price_calculation.ron | 44 + .../loot_tables/loot_table_villager.ron | 2 +- assets/common/professions.ron | 110 ++ client/src/lib.rs | 41 +- common/Cargo.toml | 3 + common/net/src/msg/client.rs | 4 +- common/net/src/msg/server.rs | 7 +- common/net/src/msg/world_msg.rs | 18 +- common/src/comp/agent.rs | 18 +- common/src/comp/inventory/item/mod.rs | 3 + common/src/comp/inventory/loadout_builder.rs | 147 ++- common/src/comp/inventory/mod.rs | 1 + common/src/comp/inventory/trade_pricing.rs | 368 ++++++ common/src/event.rs | 6 + common/src/generation.rs | 11 + common/src/skillset_builder.rs | 3 +- common/src/store.rs | 19 + common/src/terrain/biome.rs | 2 +- common/src/trade.rs | 69 ++ server/src/client.rs | 2 + server/src/cmd.rs | 3 +- server/src/events/information.rs | 87 ++ server/src/events/invite.rs | 12 +- server/src/events/mod.rs | 3 + server/src/events/trade.rs | 106 +- server/src/rtsim/entity.rs | 2 +- server/src/rtsim/tick.rs | 1 + server/src/sys/agent.rs | 262 +++- server/src/sys/msg/in_game.rs | 3 + server/src/sys/terrain.rs | 11 +- voxygen/src/hud/map.rs | 85 +- voxygen/src/hud/minimap.rs | 3 +- voxygen/src/hud/mod.rs | 6 +- voxygen/src/session.rs | 4 + voxygen/src/ui/widgets/tooltip.rs | 12 +- world/Cargo.toml | 3 +- world/economy_testinput.ron | 220 ++++ world/examples/water.rs | 5 +- world/src/civ/mod.rs | 560 ++------- world/src/index.rs | 7 +- world/src/lib.rs | 15 +- world/src/sim2/mod.rs | 1088 +++++++++++++++-- world/src/site/economy.gv | 68 ++ world/src/site/economy.rs | 446 +++++-- world/src/site/mod.rs | 14 +- world/src/site/settlement/mod.rs | 26 + world/src/util/map_vec.rs | 16 + 49 files changed, 3115 insertions(+), 834 deletions(-) create mode 100644 assets/common/item_price_calculation.ron create mode 100644 assets/common/professions.ron create mode 100644 common/src/comp/inventory/trade_pricing.rs create mode 100644 server/src/events/information.rs create mode 100644 world/economy_testinput.ron create mode 100644 world/src/site/economy.gv 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(),