From 34f580dfaa0e308f8cecbc1a95b2e19377555426 Mon Sep 17 00:00:00 2001 From: Ben Wallis Date: Sat, 28 May 2022 12:06:49 +0000 Subject: [PATCH] Introduced loot ownership rules to combat loot stealing by players * Added `LootOwner` component used to indicate that an `ItemDrop` entity is owned by another entity * A loot winner is now calculated after EXP allocation using the EXP per entity for weighted chance distribution * Used existing Inventory Full overitem text to show "Owned by {player} for {seconds}secs" when a pickup fails due to a loot ownership check * Updated agent code to take into account loot ownership when searching for `ItemDrop` targets to pick up * Added `loot` ECS system to clear expired loot ownerships --- CHANGELOG.md | 1 + assets/voxygen/i18n/en/hud/misc.ron | 2 + client/src/lib.rs | 4 +- common/net/src/sync/sync_ext.rs | 2 +- common/net/src/synced_components.rs | 5 ++ common/src/comp/inventory/mod.rs | 16 ++++- common/src/comp/loot_owner.rs | 63 +++++++++++++++++ common/src/comp/mod.rs | 4 +- common/state/src/state.rs | 1 + server/src/cmd.rs | 3 +- server/src/events/entity_manipulation.rs | 79 +++++++++++++++++---- server/src/events/interaction.rs | 3 +- server/src/events/inventory_manip.rs | 87 ++++++++++++++++++------ server/src/lib.rs | 9 +-- server/src/state_ext.rs | 7 +- server/src/sys/agent.rs | 39 ++++++----- server/src/sys/agent/data.rs | 4 +- server/src/sys/loot.rs | 42 ++++++++++++ server/src/sys/mod.rs | 1 + server/src/sys/msg/mod.rs | 6 +- voxygen/src/audio/sfx/mod.rs | 4 +- voxygen/src/hud/mod.rs | 76 ++++++++++++++++++--- voxygen/src/hud/overitem.rs | 30 ++++++-- voxygen/src/session/mod.rs | 28 ++++++-- 24 files changed, 424 insertions(+), 92 deletions(-) create mode 100644 common/src/comp/loot_owner.rs create mode 100644 server/src/sys/loot.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b5d214bc69..cbebb7eae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Modular weapons - Added Thai translation - Skiing and ice skating +- Added loot ownership for NPC drops ### Changed diff --git a/assets/voxygen/i18n/en/hud/misc.ron b/assets/voxygen/i18n/en/hud/misc.ron index 1d54252a6a..ca4946ada8 100644 --- a/assets/voxygen/i18n/en/hud/misc.ron +++ b/assets/voxygen/i18n/en/hud/misc.ron @@ -10,6 +10,8 @@ "hud.waypoint_saved": "Waypoint Saved", "hud.sp_arrow_txt": "SP", "hud.inventory_full": "Inventory Full", + "hud.someone_else": "someone else", + "hud.owned_by_for_secs": "Owned by {name} for {secs} secs", "hud.press_key_to_show_keybindings_fmt": "[{key}] Keybindings", "hud.press_key_to_toggle_lantern_fmt": "[{key}] Lantern", diff --git a/client/src/lib.rs b/client/src/lib.rs index beb9326285..fe02b0031c 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -2133,8 +2133,8 @@ impl Client { }, ServerGeneral::InventoryUpdate(inventory, event) => { match event { - InventoryUpdateEvent::BlockCollectFailed(_) => {}, - InventoryUpdateEvent::EntityCollectFailed(_) => {}, + InventoryUpdateEvent::BlockCollectFailed { .. } => {}, + InventoryUpdateEvent::EntityCollectFailed { .. } => {}, _ => { // Push the updated inventory component to the client // FIXME: Figure out whether this error can happen under normal gameplay, diff --git a/common/net/src/sync/sync_ext.rs b/common/net/src/sync/sync_ext.rs index a84ea0bdd3..e6ba3dcd79 100644 --- a/common/net/src/sync/sync_ext.rs +++ b/common/net/src/sync/sync_ext.rs @@ -74,7 +74,7 @@ impl WorldSyncExt for specs::World { self.read_storage::().get(entity).copied() } - /// Get the UID of an entity + /// Get an entity from a UID fn entity_from_uid(&self, uid: u64) -> Option { self.read_resource::() .retrieve_entity_internal(uid) diff --git a/common/net/src/synced_components.rs b/common/net/src/synced_components.rs index 374931560c..2675eec32b 100644 --- a/common/net/src/synced_components.rs +++ b/common/net/src/synced_components.rs @@ -62,6 +62,7 @@ macro_rules! synced_components { combo: Combo, active_abilities: ActiveAbilities, can_build: CanBuild, + loot_owner: LootOwner, } }; } @@ -234,3 +235,7 @@ impl NetSync for ActiveAbilities { impl NetSync for CanBuild { const SYNC_FROM: SyncFrom = SyncFrom::ClientEntity; } + +impl NetSync for LootOwner { + const SYNC_FROM: SyncFrom = SyncFrom::AnyEntity; +} diff --git a/common/src/comp/inventory/mod.rs b/common/src/comp/inventory/mod.rs index 616f25e5c9..37c4e33070 100644 --- a/common/src/comp/inventory/mod.rs +++ b/common/src/comp/inventory/mod.rs @@ -803,6 +803,12 @@ impl Component for Inventory { type Storage = DerefFlaggedStorage>; } +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +pub enum CollectFailedReason { + InventoryFull, + LootOwned { owner_uid: Uid, expiry_secs: u64 }, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub enum InventoryUpdateEvent { Init, @@ -813,8 +819,14 @@ pub enum InventoryUpdateEvent { Swapped, Dropped, Collected(Item), - BlockCollectFailed(Vec3), - EntityCollectFailed(Uid), + BlockCollectFailed { + pos: Vec3, + reason: CollectFailedReason, + }, + EntityCollectFailed { + entity: Uid, + reason: CollectFailedReason, + }, Possession, Debug, Craft, diff --git a/common/src/comp/loot_owner.rs b/common/src/comp/loot_owner.rs new file mode 100644 index 0000000000..1f02a22ae0 --- /dev/null +++ b/common/src/comp/loot_owner.rs @@ -0,0 +1,63 @@ +use crate::{ + comp::{Alignment, Body, Player}, + uid::Uid, +}; +use serde::{Deserialize, Serialize}; +use specs::{Component, DerefFlaggedStorage}; +use specs_idvs::IdvStorage; +use std::{ + ops::Add, + time::{Duration, Instant}, +}; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +pub struct LootOwner { + // TODO: Fix this if expiry is needed client-side, Instant is not serializable + #[serde(skip, default = "Instant::now")] + expiry: Instant, + owner_uid: Uid, +} + +// Loot becomes free-for-all after the initial ownership period +const OWNERSHIP_SECS: u64 = 45; + +impl LootOwner { + pub fn new(uid: Uid) -> Self { + Self { + expiry: Instant::now().add(Duration::from_secs(OWNERSHIP_SECS)), + owner_uid: uid, + } + } + + pub fn uid(&self) -> Uid { self.owner_uid } + + pub fn time_until_expiration(&self) -> Duration { self.expiry - Instant::now() } + + pub fn expired(&self) -> bool { self.expiry <= Instant::now() } + + pub fn default_instant() -> Instant { Instant::now() } + + pub fn can_pickup( + &self, + uid: Uid, + alignment: Option<&Alignment>, + body: Option<&Body>, + player: Option<&Player>, + ) -> bool { + let is_owned = matches!(alignment, Some(Alignment::Owned(_))); + let is_player = player.is_some(); + let is_pet = is_owned && !is_player; + + let owns_loot = self.uid().0 == uid.0; + let is_humanoid = matches!(body, Some(Body::Humanoid(_))); + + // Pet's can't pick up owned loot + // Humanoids must own the loot + // Non-humanoids ignore loot ownership + !is_pet && (owns_loot || !is_humanoid) + } +} + +impl Component for LootOwner { + type Storage = DerefFlaggedStorage>; +} diff --git a/common/src/comp/mod.rs b/common/src/comp/mod.rs index ba7d19c833..4da189e746 100644 --- a/common/src/comp/mod.rs +++ b/common/src/comp/mod.rs @@ -29,6 +29,7 @@ pub mod inventory; pub mod invite; #[cfg(not(target_arch = "wasm32"))] mod last; #[cfg(not(target_arch = "wasm32"))] mod location; +pub mod loot_owner; #[cfg(not(target_arch = "wasm32"))] pub mod melee; #[cfg(not(target_arch = "wasm32"))] mod misc; #[cfg(not(target_arch = "wasm32"))] pub mod ori; @@ -87,10 +88,11 @@ pub use self::{ tool::{self, AbilityItem}, Item, ItemConfig, ItemDrop, }, - slot, Inventory, InventoryUpdate, InventoryUpdateEvent, + slot, CollectFailedReason, Inventory, InventoryUpdate, InventoryUpdateEvent, }, last::Last, location::{MapMarker, MapMarkerChange, MapMarkerUpdate, Waypoint, WaypointArea}, + loot_owner::LootOwner, melee::{Melee, MeleeConstructor}, misc::Object, ori::Ori, diff --git a/common/state/src/state.rs b/common/state/src/state.rs index 3c5f13cb1b..6697f30394 100644 --- a/common/state/src/state.rs +++ b/common/state/src/state.rs @@ -157,6 +157,7 @@ impl State { ecs.register::(); ecs.register::(); ecs.register::(); + ecs.register::(); // Register components send from clients -> server ecs.register::(); diff --git a/server/src/cmd.rs b/server/src/cmd.rs index e4393c156d..eba2cc0870 100644 --- a/server/src/cmd.rs +++ b/server/src/cmd.rs @@ -500,13 +500,12 @@ fn handle_drop_all( server .state - .create_item_drop(Default::default(), &item) + .create_item_drop(Default::default(), item) .with(comp::Pos(Vec3::new( pos.0.x + rng.gen_range(5.0..10.0), pos.0.y + rng.gen_range(5.0..10.0), pos.0.z + 5.0, ))) - .with(item) .with(comp::Vel(vel)) .build(); } diff --git a/server/src/events/entity_manipulation.rs b/server/src/events/entity_manipulation.rs index 29499abff7..d14a109ad4 100644 --- a/server/src/events/entity_manipulation.rs +++ b/server/src/events/entity_manipulation.rs @@ -3,6 +3,7 @@ use crate::{ comp::{ ability, agent::{Agent, AgentEvent, Sound, SoundKind}, + loot_owner::LootOwner, skillset::SkillGroupKind, BuffKind, BuffSource, PhysicsState, }, @@ -34,7 +35,8 @@ use common_net::{msg::ServerGeneral, sync::WorldSyncExt}; use common_state::BlockChange; use comp::chat::GenericChatMsg; use hashbrown::HashSet; -use rand::Rng; +use rand::{distributions::WeightedIndex, Rng}; +use rand_distr::Distribution; use specs::{ join::Join, saveload::MarkerAllocator, Builder, Entity as EcsEntity, Entity, WorldExt, }; @@ -203,6 +205,7 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt } } + let mut exp_awards = Vec::<(Entity, f32)>::new(); // Award EXP to damage contributors // // NOTE: Debug logging is disabled by default for this module - to enable it add @@ -313,7 +316,7 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt // Iterate through all contributors of damage for the killed entity, calculating // how much EXP each contributor should be awarded based on their // percentage of damage contribution - damage_contributors.iter().filter_map(|(damage_contributor, (_, damage_percent))| { + exp_awards = damage_contributors.iter().filter_map(|(damage_contributor, (_, damage_percent))| { let contributor_exp = exp_reward * damage_percent; match damage_contributor { DamageContrib::Solo(attacker) => { @@ -365,16 +368,23 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt None } } - }).flatten().for_each(|(attacker, exp_reward)| { + }).flatten().collect::>(); + + exp_awards.iter().for_each(|(attacker, exp_reward)| { // Process the calculated EXP rewards - if let (Some(mut attacker_skill_set), Some(attacker_uid), Some(attacker_inventory), Some(pos)) = ( - skill_sets.get_mut(attacker), - uids.get(attacker), - inventories.get(attacker), - positions.get(attacker), + if let ( + Some(mut attacker_skill_set), + Some(attacker_uid), + Some(attacker_inventory), + Some(pos), + ) = ( + skill_sets.get_mut(*attacker), + uids.get(*attacker), + inventories.get(*attacker), + positions.get(*attacker), ) { handle_exp_gain( - exp_reward, + *exp_reward, attacker_inventory, &mut attacker_skill_set, attacker_uid, @@ -437,14 +447,53 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt let pos = state.ecs().read_storage::().get(entity).cloned(); let vel = state.ecs().read_storage::().get(entity).cloned(); if let Some(pos) = pos { - // TODO: This should only be temporary as you'd eventually want to actually - // render the items on the ground, rather than changing the texture depending on - // the body type - let _ = state - .create_item_drop(comp::Pos(pos.0 + Vec3::unit_z() * 0.25), &item) + let winner_uid = if exp_awards.is_empty() { + None + } else { + // Use the awarded exp per entity as the weight distribution for drop chance + // Creating the WeightedIndex can only fail if there are weights <= 0 or no + // weights, which shouldn't ever happen + let dist = WeightedIndex::new(exp_awards.iter().map(|x| x.1)) + .expect("Failed to create WeightedIndex for loot drop chance"); + let mut rng = rand::thread_rng(); + let winner = exp_awards + .get(dist.sample(&mut rng)) + .expect("Loot distribution failed to find a winner") + .0; + + state + .ecs() + .read_storage::() + .get(winner) + .and_then(|body| { + // Only humanoids are awarded loot ownership - if the winner was a + // non-humanoid NPC the loot will be free-for-all + if matches!(body, Body::Humanoid(_)) { + Some(state.ecs().read_storage::().get(winner).cloned()) + } else { + None + } + }) + .flatten() + }; + + let item_drop_entity = state + .create_item_drop(comp::Pos(pos.0 + Vec3::unit_z() * 0.25), item) .maybe_with(vel) - .with(item) .build(); + + // If there was a loot winner, assign them as the owner of the loot. There will + // not be a loot winner when an entity dies to environment damage and such so + // the loot will be free-for-all. + if let Some(uid) = winner_uid { + debug!("Assigned UID {:?} as the winner for the loot drop", uid); + + state + .ecs() + .write_storage::() + .insert(item_drop_entity, LootOwner::new(uid)) + .unwrap(); + } } else { error!( ?entity, diff --git a/server/src/events/interaction.rs b/server/src/events/interaction.rs index 1ea2218dc7..c08cbca453 100644 --- a/server/src/events/interaction.rs +++ b/server/src/events/interaction.rs @@ -233,9 +233,8 @@ pub fn handle_mine_block( } } state - .create_item_drop(Default::default(), &item) + .create_item_drop(Default::default(), item) .with(comp::Pos(pos.map(|e| e as f32) + Vec3::new(0.5, 0.5, 0.0))) - .with(item) .build(); } diff --git a/server/src/events/inventory_manip.rs b/server/src/events/inventory_manip.rs index 1186bbbb6d..b9f5aa1437 100644 --- a/server/src/events/inventory_manip.rs +++ b/server/src/events/inventory_manip.rs @@ -24,7 +24,10 @@ use comp::LightEmitter; use crate::{client::Client, Server, StateExt}; use common::{ - comp::{pet::is_tameable, ChatType, Group}, + comp::{ + pet::is_tameable, Alignment, Body, ChatType, CollectFailedReason, Group, + InventoryUpdateEvent, Player, + }, event::{EventBus, ServerEvent}, }; use common_net::msg::ServerGeneral; @@ -109,27 +112,66 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv } match manip { - comp::InventoryManip::Pickup(uid) => { - let item_entity = if let Some(item_entity) = state.ecs().entity_from_uid(uid.into()) { - item_entity - } else { - // Item entity could not be found - most likely because the entity - // attempted to pick up the same item very quickly before its deletion of the - // world from the first pickup attempt was processed. - debug!("Failed to get entity for item Uid: {}", uid); - return; - }; + comp::InventoryManip::Pickup(pickup_uid) => { + let item_entity = + if let Some(item_entity) = state.ecs().entity_from_uid(pickup_uid.into()) { + item_entity + } else { + // Item entity could not be found - most likely because the entity + // attempted to pick up the same item very quickly before its deletion of the + // world from the first pickup attempt was processed. + debug!("Failed to get entity for item Uid: {}", pickup_uid); + return; + }; let entity_cylinder = get_cylinder(state, entity); // FIXME: Raycast so we can't pick up items through walls. if !within_pickup_range(entity_cylinder, || get_cylinder(state, item_entity)) { debug!( ?entity_cylinder, - "Failed to pick up item as not within range, Uid: {}", uid + "Failed to pick up item as not within range, Uid: {}", pickup_uid ); return; } + let loot_owner_storage = state.ecs().read_storage::(); + + // If there's a loot owner for the item being picked up, then + // determine whether the pickup should be rejected. + let ownership_check_passed = state + .ecs() + .read_storage::() + .get(item_entity) + .map_or(true, |loot_owner| { + let alignments = state.ecs().read_storage::(); + let bodies = state.ecs().read_storage::(); + let players = state.ecs().read_storage::(); + let can_pickup = loot_owner.can_pickup( + uid, + alignments.get(entity), + bodies.get(entity), + players.get(entity), + ); + if !can_pickup { + let event = + comp::InventoryUpdate::new(InventoryUpdateEvent::EntityCollectFailed { + entity: pickup_uid, + reason: CollectFailedReason::LootOwned { + owner_uid: loot_owner.uid(), + expiry_secs: loot_owner.time_until_expiration().as_secs(), + }, + }); + state.ecs().write_storage().insert(entity, event).unwrap(); + } + can_pickup + }); + + if !ownership_check_passed { + return; + } + + drop(loot_owner_storage); + // First, we remove the item, assuming picking it up will succeed (we do this to // avoid cloning the item, as we should not call Item::clone and it // may be removed!). @@ -140,7 +182,10 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv // Item component could not be found - most likely because the entity // attempted to pick up the same item very quickly before its deletion of the // world from the first pickup attempt was processed. - debug!("Failed to delete item component for entity, Uid: {}", uid); + debug!( + "Failed to delete item component for entity, Uid: {}", + pickup_uid + ); return; }; @@ -165,7 +210,10 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv ); drop(item_storage); drop(inventories); - comp::InventoryUpdate::new(comp::InventoryUpdateEvent::EntityCollectFailed(uid)) + comp::InventoryUpdate::new(comp::InventoryUpdateEvent::EntityCollectFailed { + entity: pickup_uid, + reason: comp::CollectFailedReason::InventoryFull, + }) }, Ok(_) => { // We succeeded in picking up the item, so we may now delete its old entity @@ -219,7 +267,10 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv Err(_) => { drop_item = Some(item_msg); comp::InventoryUpdate::new( - comp::InventoryUpdateEvent::BlockCollectFailed(pos), + comp::InventoryUpdateEvent::BlockCollectFailed { + pos, + reason: comp::CollectFailedReason::InventoryFull, + }, ) }, }; @@ -248,11 +299,10 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv drop(inventories); if let Some(item) = drop_item { state - .create_item_drop(Default::default(), &item) + .create_item_drop(Default::default(), item) .with(comp::Pos( Vec3::new(pos.x as f32, pos.y as f32, pos.z as f32) + Vec3::unit_z(), )) - .with(item) .with(comp::Vel(Vec3::zero())) .build(); } @@ -771,9 +821,8 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv }); state - .create_item_drop(Default::default(), &item) + .create_item_drop(Default::default(), item) .with(comp::Pos(pos.0 + *ori.look_dir() + Vec3::unit_z())) - .with(item) .with(comp::Vel(Vec3::zero())) .build(); } diff --git a/server/src/lib.rs b/server/src/lib.rs index 23a5739920..8a405e73eb 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -2,14 +2,15 @@ #![allow(clippy::option_map_unit_fn)] #![deny(clippy::clone_on_ref_ptr)] #![feature( - box_patterns, - label_break_value, bool_to_option, + box_patterns, drain_filter, + label_break_value, + let_chains, + let_else, never_type, option_zip, - unwrap_infallible, - let_else + unwrap_infallible )] #![cfg_attr(not(feature = "worldgen"), feature(const_panic))] diff --git a/server/src/state_ext.rs b/server/src/state_ext.rs index ff999e8a2a..ef99603136 100644 --- a/server/src/state_ext.rs +++ b/server/src/state_ext.rs @@ -55,7 +55,7 @@ pub trait StateExt { ) -> EcsEntityBuilder; /// Build a static object entity fn create_object(&mut self, pos: comp::Pos, object: comp::object::Body) -> EcsEntityBuilder; - fn create_item_drop(&mut self, pos: comp::Pos, item: &Item) -> EcsEntityBuilder; + fn create_item_drop(&mut self, pos: comp::Pos, item: Item) -> EcsEntityBuilder; fn create_ship comp::Collider>( &mut self, pos: comp::Pos, @@ -271,11 +271,12 @@ impl StateExt for State { .with(body) } - fn create_item_drop(&mut self, pos: comp::Pos, item: &Item) -> EcsEntityBuilder { - let item_drop = comp::item_drop::Body::from(item); + fn create_item_drop(&mut self, pos: comp::Pos, item: Item) -> EcsEntityBuilder { + let item_drop = comp::item_drop::Body::from(&item); let body = comp::Body::ItemDrop(item_drop); self.ecs_mut() .create_entity_synced() + .with(item) .with(pos) .with(comp::Vel(Vec3::zero())) .with(item_drop.orientation(&mut thread_rng())) diff --git a/server/src/sys/agent.rs b/server/src/sys/agent.rs index 094cffe8d8..1315f593ea 100644 --- a/server/src/sys/agent.rs +++ b/server/src/sys/agent.rs @@ -1603,22 +1603,29 @@ impl<'a> AgentData<'a> { }; let is_valid_target = |entity: EcsEntity| match read_data.bodies.get(entity) { Some(Body::ItemDrop(item)) => { - //If statement that checks either if the self (agent) is a humanoid, - //or if the self is not a humanoid, it checks whether or not you are 'hungry' - - // meaning less than full health - and additionally checks if - // the target entity is a consumable item. If it qualifies for - // either being a humanoid or a hungry non-humanoid that likes consumables, - // it will choose the item as its target. - if matches!(self.body, Some(Body::Humanoid(_))) - || (self - .health - .map_or(false, |health| health.current() < health.maximum()) - && matches!(item, item_drop::Body::Consumable)) - { - Some((entity, false)) - } else { - None - } + // If there is no LootOwner then the ItemDrop is a valid target, otherwise check + // if the loot can be picked up + read_data + .loot_owners + .get(entity) + .map_or(Some((entity, false)), |loot_owner| { + // Agents want to pick up items if they are humanoid, or are hungry and the + // item is consumable + let hungry = self + .health + .map_or(false, |health| health.current() < health.maximum()); + let wants_pickup = matches!(self.body, Some(Body::Humanoid(_))) + || (hungry && matches!(item, item_drop::Body::Consumable)); + + let can_pickup = + loot_owner.can_pickup(*self.uid, self.alignment, self.body, None); + + if wants_pickup && can_pickup { + Some((entity, false)) + } else { + None + } + }) }, _ => { if read_data.healths.get(entity).map_or(false, |health| { diff --git a/server/src/sys/agent/data.rs b/server/src/sys/agent/data.rs index 67a68fd7b5..f89de2a7b7 100644 --- a/server/src/sys/agent/data.rs +++ b/server/src/sys/agent/data.rs @@ -2,7 +2,8 @@ use crate::rtsim::Entity as RtSimData; use common::{ comp::{ buff::Buffs, group, ActiveAbilities, Alignment, Body, CharacterState, Combo, Energy, - Health, Inventory, LightEmitter, Ori, PhysicsState, Pos, Scale, SkillSet, Stats, Vel, + Health, Inventory, LightEmitter, LootOwner, Ori, PhysicsState, Pos, Scale, SkillSet, Stats, + Vel, }, link::Is, mounting::Mount, @@ -152,6 +153,7 @@ pub struct ReadData<'a> { pub buffs: ReadStorage<'a, Buffs>, pub combos: ReadStorage<'a, Combo>, pub active_abilities: ReadStorage<'a, ActiveAbilities>, + pub loot_owners: ReadStorage<'a, LootOwner>, } pub enum Path { diff --git a/server/src/sys/loot.rs b/server/src/sys/loot.rs new file mode 100644 index 0000000000..6c991d64d2 --- /dev/null +++ b/server/src/sys/loot.rs @@ -0,0 +1,42 @@ +use common::{comp::LootOwner, uid::UidAllocator}; +use common_ecs::{Job, Origin, Phase, System}; +use specs::{saveload::MarkerAllocator, Entities, Entity, Join, Read, WriteStorage}; +use tracing::debug; + +// This system manages loot that exists in the world +#[derive(Default)] +pub struct Sys; +impl<'a> System<'a> for Sys { + type SystemData = ( + Entities<'a>, + WriteStorage<'a, LootOwner>, + Read<'a, UidAllocator>, + ); + + const NAME: &'static str = "loot"; + const ORIGIN: Origin = Origin::Server; + const PHASE: Phase = Phase::Create; + + fn run(_job: &mut Job, (entities, mut loot_owners, uid_allocator): Self::SystemData) { + // Find and remove expired loot ownership. Loot ownership is expired when either + // the expiry time has passed, or the owner entity no longer exists + let expired = (&entities, &loot_owners) + .join() + .filter(|(_, loot_owner)| { + loot_owner.expired() + || uid_allocator + .retrieve_entity_internal(loot_owner.uid().into()) + .map_or(true, |entity| !entities.is_alive(entity)) + }) + .map(|(entity, _)| entity) + .collect::>(); + + if !&expired.is_empty() { + debug!("Removing {} expired loot ownerships", expired.iter().len()); + } + + for entity in expired { + loot_owners.remove(entity); + } + } +} diff --git a/server/src/sys/mod.rs b/server/src/sys/mod.rs index 56ac301431..59e2b446f6 100644 --- a/server/src/sys/mod.rs +++ b/server/src/sys/mod.rs @@ -3,6 +3,7 @@ pub mod chunk_send; pub mod chunk_serialize; pub mod entity_sync; pub mod invite_timeout; +pub mod loot; pub mod metrics; pub mod msg; pub mod object; diff --git a/server/src/sys/msg/mod.rs b/server/src/sys/msg/mod.rs index 0edd89ecef..f69be4351a 100644 --- a/server/src/sys/msg/mod.rs +++ b/server/src/sys/msg/mod.rs @@ -5,7 +5,10 @@ pub mod ping; pub mod register; pub mod terrain; -use crate::{client::Client, sys::pets}; +use crate::{ + client::Client, + sys::{loot, pets}, +}; use common_ecs::{dispatch, System}; use serde::de::DeserializeOwned; use specs::DispatcherBuilder; @@ -20,6 +23,7 @@ pub fn add_server_systems(dispatch_builder: &mut DispatcherBuilder) { dispatch::(dispatch_builder, &[]); dispatch::(dispatch_builder, &[]); dispatch::(dispatch_builder, &[]); + dispatch::(dispatch_builder, &[]); } /// handles all send msg and calls a handle fn diff --git a/voxygen/src/audio/sfx/mod.rs b/voxygen/src/audio/sfx/mod.rs index 9e359f9570..e0f7abb266 100644 --- a/voxygen/src/audio/sfx/mod.rs +++ b/voxygen/src/audio/sfx/mod.rs @@ -314,8 +314,8 @@ impl From<&InventoryUpdateEvent> for SfxEvent { _ => SfxEvent::Inventory(SfxInventoryEvent::Collected), } }, - InventoryUpdateEvent::BlockCollectFailed(_) - | InventoryUpdateEvent::EntityCollectFailed(_) => { + InventoryUpdateEvent::BlockCollectFailed { .. } + | InventoryUpdateEvent::EntityCollectFailed { .. } => { SfxEvent::Inventory(SfxInventoryEvent::CollectFailed) }, InventoryUpdateEvent::Consumed(consumable) => { diff --git a/voxygen/src/hud/mod.rs b/voxygen/src/hud/mod.rs index 3803fb0ec6..3ac004bb85 100644 --- a/voxygen/src/hud/mod.rs +++ b/voxygen/src/hud/mod.rs @@ -80,7 +80,7 @@ use common::{ self, ability::AuxiliaryAbility, fluid_dynamics, - inventory::{slot::InvSlotId, trade_pricing::TradePricing}, + inventory::{slot::InvSlotId, trade_pricing::TradePricing, CollectFailedReason}, item::{tool::ToolKind, ItemDesc, MaterialStatManifest, Quality}, pet::is_mountable, skillset::{skills::Skill, SkillGroupKind}, @@ -997,6 +997,58 @@ pub struct Floaters { pub block_floaters: Vec, } +#[derive(Clone)] +pub enum HudLootOwner { + Name(String), + Unknown, +} + +#[derive(Clone)] +pub enum HudCollectFailedReason { + InventoryFull, + LootOwned { + owner: HudLootOwner, + expiry_secs: u64, + }, +} + +impl HudCollectFailedReason { + pub fn from_server_reason(reason: &CollectFailedReason, ecs: &specs::World) -> Self { + match reason { + CollectFailedReason::InventoryFull => HudCollectFailedReason::InventoryFull, + CollectFailedReason::LootOwned { + owner_uid, + expiry_secs, + } => { + let maybe_owner_name = + ecs.entity_from_uid((*owner_uid).into()).and_then(|entity| { + ecs.read_storage::() + .get(entity) + .map(|stats| stats.name.clone()) + }); + let owner = if let Some(name) = maybe_owner_name { + HudLootOwner::Name(name) + } else { + HudLootOwner::Unknown + }; + HudCollectFailedReason::LootOwned { + owner, + expiry_secs: *expiry_secs, + } + }, + } + } +} +#[derive(Clone)] +pub struct CollectFailedData { + pulse: f32, + reason: HudCollectFailedReason, +} + +impl CollectFailedData { + pub fn new(pulse: f32, reason: HudCollectFailedReason) -> Self { Self { pulse, reason } } +} + pub struct Hud { ui: Ui, ids: Ids, @@ -1005,8 +1057,8 @@ pub struct Hud { item_imgs: ItemImgs, fonts: Fonts, rot_imgs: ImgsRot, - failed_block_pickups: HashMap, f32>, - failed_entity_pickups: HashMap, + failed_block_pickups: HashMap, CollectFailedData>, + failed_entity_pickups: HashMap, new_loot_messages: VecDeque, new_messages: VecDeque, new_notifications: VecDeque, @@ -1772,9 +1824,9 @@ impl Hud { }; self.failed_block_pickups - .retain(|_, t| pulse - *t < overitem::PICKUP_FAILED_FADE_OUT_TIME); + .retain(|_, t| pulse - (*t).pulse < overitem::PICKUP_FAILED_FADE_OUT_TIME); self.failed_entity_pickups - .retain(|_, t| pulse - *t < overitem::PICKUP_FAILED_FADE_OUT_TIME); + .retain(|_, t| pulse - (*t).pulse < overitem::PICKUP_FAILED_FADE_OUT_TIME); // Render overitem: name, etc. for (entity, pos, item, distance) in (&entities, &pos, &items) @@ -1793,7 +1845,7 @@ impl Hud { distance, overitem::OveritemProperties { active: interactable.as_ref().and_then(|i| i.entity()) == Some(entity), - pickup_failed_pulse: self.failed_entity_pickups.get(&entity).copied(), + pickup_failed_pulse: self.failed_entity_pickups.get(&entity).cloned(), }, &self.fonts, vec![(GameInput::Interact, i18n.get("hud.pick_up").to_string())], @@ -1810,7 +1862,7 @@ impl Hud { let overitem_properties = overitem::OveritemProperties { active: true, - pickup_failed_pulse: self.failed_block_pickups.get(&pos).copied(), + pickup_failed_pulse: self.failed_block_pickups.get(&pos).cloned(), }; let pos = pos.map(|e| e as f32 + 0.5); let over_pos = pos + Vec3::unit_z() * 0.7; @@ -3938,12 +3990,14 @@ impl Hud { } } - pub fn add_failed_block_pickup(&mut self, pos: Vec3) { - self.failed_block_pickups.insert(pos, self.pulse); + pub fn add_failed_block_pickup(&mut self, pos: Vec3, reason: HudCollectFailedReason) { + self.failed_block_pickups + .insert(pos, CollectFailedData::new(self.pulse, reason)); } - pub fn add_failed_entity_pickup(&mut self, entity: EcsEntity) { - self.failed_entity_pickups.insert(entity, self.pulse); + pub fn add_failed_entity_pickup(&mut self, entity: EcsEntity, reason: HudCollectFailedReason) { + self.failed_entity_pickups + .insert(entity, CollectFailedData::new(self.pulse, reason)); } pub fn new_loot_message(&mut self, item: LootMessage) { diff --git a/voxygen/src/hud/overitem.rs b/voxygen/src/hud/overitem.rs index aaf6425f8f..16ca78a37e 100644 --- a/voxygen/src/hud/overitem.rs +++ b/voxygen/src/hud/overitem.rs @@ -11,6 +11,7 @@ use conrod_core::{ use i18n::Localization; use std::borrow::Cow; +use crate::hud::{CollectFailedData, HudCollectFailedReason, HudLootOwner}; use keyboard_keynames::key_layout::KeyLayout; pub const TEXT_COLOR: Color = Color::Rgba(0.61, 0.61, 0.89, 1.0); @@ -79,7 +80,7 @@ impl<'a> Overitem<'a> { pub struct OveritemProperties { pub active: bool, - pub pickup_failed_pulse: Option, + pub pickup_failed_pulse: Option, } pub struct State { @@ -215,9 +216,10 @@ impl<'a> Widget for Overitem<'a> { .parent(id) .set(state.ids.btn_bg, ui); } - if let Some(time) = self.properties.pickup_failed_pulse { + if let Some(collect_failed_data) = self.properties.pickup_failed_pulse { //should never exceed 1.0, but just in case - let age = ((self.pulse - time) / PICKUP_FAILED_FADE_OUT_TIME).clamp(0.0, 1.0); + let age = ((self.pulse - collect_failed_data.pulse) / PICKUP_FAILED_FADE_OUT_TIME) + .clamp(0.0, 1.0); let alpha = 1.0 - age.powi(4); let brightness = 1.0 / (age / 0.07 - 1.0).abs().clamp(0.01, 1.0); @@ -226,7 +228,25 @@ impl<'a> Widget for Overitem<'a> { color::hsla(hue, sat / brightness, lum * brightness.sqrt(), alp * alpha) }; - Text::new(self.localized_strings.get("hud.inventory_full")) + let text = match collect_failed_data.reason { + HudCollectFailedReason::InventoryFull => { + self.localized_strings.get("hud.inventory_full").to_string() + }, + HudCollectFailedReason::LootOwned { owner, expiry_secs } => { + let owner_name = match owner { + HudLootOwner::Name(name) => name, + HudLootOwner::Unknown => { + self.localized_strings.get("hud.someone_else").to_string() + }, + }; + self.localized_strings + .get("hud.owned_by_for_secs") + .replace("{name}", &owner_name) + .replace("{secs}", format!("{}", expiry_secs).as_str()) + }, + }; + + Text::new(&text) .font_id(self.fonts.cyri.conrod_id) .font_size(inv_full_font_size as u32) .color(shade_color(Color::Rgba(0.0, 0.0, 0.0, 1.0))) @@ -235,7 +255,7 @@ impl<'a> Widget for Overitem<'a> { .depth(self.distance_from_player_sqr + 6.0) .set(state.ids.inv_full_bg, ui); - Text::new(self.localized_strings.get("hud.inventory_full")) + Text::new(&text) .font_id(self.fonts.cyri.conrod_id) .font_size(inv_full_font_size as u32) .color(shade_color(Color::Rgba(1.0, 0.0, 0.0, 1.0))) diff --git a/voxygen/src/session/mod.rs b/voxygen/src/session/mod.rs index fe14ed81b9..d9af27e1b5 100644 --- a/voxygen/src/session/mod.rs +++ b/voxygen/src/session/mod.rs @@ -43,7 +43,10 @@ use crate::{ audio::sfx::SfxEvent, error::Error, game_input::GameInput, - hud::{DebugInfo, Event as HudEvent, Hud, HudInfo, LootMessage, PromptDialogSettings}, + hud::{ + DebugInfo, Event as HudEvent, Hud, HudCollectFailedReason, HudInfo, LootMessage, + PromptDialogSettings, + }, key_state::KeyState, menu::char_selection::CharSelectionState, render::{Drawer, GlobalsBindGroup}, @@ -244,12 +247,27 @@ impl SessionState { global_state.audio.emit_sfx_item(sfx_trigger_item); match inv_event { - InventoryUpdateEvent::BlockCollectFailed(pos) => { - self.hud.add_failed_block_pickup(pos); + InventoryUpdateEvent::BlockCollectFailed { pos, reason } => { + self.hud.add_failed_block_pickup( + pos, + HudCollectFailedReason::from_server_reason( + &reason, + client.state().ecs(), + ), + ); }, - InventoryUpdateEvent::EntityCollectFailed(uid) => { + InventoryUpdateEvent::EntityCollectFailed { + entity: uid, + reason, + } => { if let Some(entity) = client.state().ecs().entity_from_uid(uid.into()) { - self.hud.add_failed_entity_pickup(entity); + self.hud.add_failed_entity_pickup( + entity, + HudCollectFailedReason::from_server_reason( + &reason, + client.state().ecs(), + ), + ); } }, InventoryUpdateEvent::Collected(item) => {