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
This commit is contained in:
Ben Wallis 2022-05-28 12:06:49 +00:00
parent bba81c65d9
commit 34f580dfaa
24 changed files with 424 additions and 92 deletions

View File

@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Modular weapons - Modular weapons
- Added Thai translation - Added Thai translation
- Skiing and ice skating - Skiing and ice skating
- Added loot ownership for NPC drops
### Changed ### Changed

View File

@ -10,6 +10,8 @@
"hud.waypoint_saved": "Waypoint Saved", "hud.waypoint_saved": "Waypoint Saved",
"hud.sp_arrow_txt": "SP", "hud.sp_arrow_txt": "SP",
"hud.inventory_full": "Inventory Full", "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_show_keybindings_fmt": "[{key}] Keybindings",
"hud.press_key_to_toggle_lantern_fmt": "[{key}] Lantern", "hud.press_key_to_toggle_lantern_fmt": "[{key}] Lantern",

View File

@ -2133,8 +2133,8 @@ impl Client {
}, },
ServerGeneral::InventoryUpdate(inventory, event) => { ServerGeneral::InventoryUpdate(inventory, event) => {
match event { match event {
InventoryUpdateEvent::BlockCollectFailed(_) => {}, InventoryUpdateEvent::BlockCollectFailed { .. } => {},
InventoryUpdateEvent::EntityCollectFailed(_) => {}, InventoryUpdateEvent::EntityCollectFailed { .. } => {},
_ => { _ => {
// Push the updated inventory component to the client // Push the updated inventory component to the client
// FIXME: Figure out whether this error can happen under normal gameplay, // FIXME: Figure out whether this error can happen under normal gameplay,

View File

@ -74,7 +74,7 @@ impl WorldSyncExt for specs::World {
self.read_storage::<Uid>().get(entity).copied() self.read_storage::<Uid>().get(entity).copied()
} }
/// Get the UID of an entity /// Get an entity from a UID
fn entity_from_uid(&self, uid: u64) -> Option<specs::Entity> { fn entity_from_uid(&self, uid: u64) -> Option<specs::Entity> {
self.read_resource::<UidAllocator>() self.read_resource::<UidAllocator>()
.retrieve_entity_internal(uid) .retrieve_entity_internal(uid)

View File

@ -62,6 +62,7 @@ macro_rules! synced_components {
combo: Combo, combo: Combo,
active_abilities: ActiveAbilities, active_abilities: ActiveAbilities,
can_build: CanBuild, can_build: CanBuild,
loot_owner: LootOwner,
} }
}; };
} }
@ -234,3 +235,7 @@ impl NetSync for ActiveAbilities {
impl NetSync for CanBuild { impl NetSync for CanBuild {
const SYNC_FROM: SyncFrom = SyncFrom::ClientEntity; const SYNC_FROM: SyncFrom = SyncFrom::ClientEntity;
} }
impl NetSync for LootOwner {
const SYNC_FROM: SyncFrom = SyncFrom::AnyEntity;
}

View File

@ -803,6 +803,12 @@ impl Component for Inventory {
type Storage = DerefFlaggedStorage<Self, IdvStorage<Self>>; type Storage = DerefFlaggedStorage<Self, IdvStorage<Self>>;
} }
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
pub enum CollectFailedReason {
InventoryFull,
LootOwned { owner_uid: Uid, expiry_secs: u64 },
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub enum InventoryUpdateEvent { pub enum InventoryUpdateEvent {
Init, Init,
@ -813,8 +819,14 @@ pub enum InventoryUpdateEvent {
Swapped, Swapped,
Dropped, Dropped,
Collected(Item), Collected(Item),
BlockCollectFailed(Vec3<i32>), BlockCollectFailed {
EntityCollectFailed(Uid), pos: Vec3<i32>,
reason: CollectFailedReason,
},
EntityCollectFailed {
entity: Uid,
reason: CollectFailedReason,
},
Possession, Possession,
Debug, Debug,
Craft, Craft,

View File

@ -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<Self, IdvStorage<Self>>;
}

View File

@ -29,6 +29,7 @@ pub mod inventory;
pub mod invite; pub mod invite;
#[cfg(not(target_arch = "wasm32"))] mod last; #[cfg(not(target_arch = "wasm32"))] mod last;
#[cfg(not(target_arch = "wasm32"))] mod location; #[cfg(not(target_arch = "wasm32"))] mod location;
pub mod loot_owner;
#[cfg(not(target_arch = "wasm32"))] pub mod melee; #[cfg(not(target_arch = "wasm32"))] pub mod melee;
#[cfg(not(target_arch = "wasm32"))] mod misc; #[cfg(not(target_arch = "wasm32"))] mod misc;
#[cfg(not(target_arch = "wasm32"))] pub mod ori; #[cfg(not(target_arch = "wasm32"))] pub mod ori;
@ -87,10 +88,11 @@ pub use self::{
tool::{self, AbilityItem}, tool::{self, AbilityItem},
Item, ItemConfig, ItemDrop, Item, ItemConfig, ItemDrop,
}, },
slot, Inventory, InventoryUpdate, InventoryUpdateEvent, slot, CollectFailedReason, Inventory, InventoryUpdate, InventoryUpdateEvent,
}, },
last::Last, last::Last,
location::{MapMarker, MapMarkerChange, MapMarkerUpdate, Waypoint, WaypointArea}, location::{MapMarker, MapMarkerChange, MapMarkerUpdate, Waypoint, WaypointArea},
loot_owner::LootOwner,
melee::{Melee, MeleeConstructor}, melee::{Melee, MeleeConstructor},
misc::Object, misc::Object,
ori::Ori, ori::Ori,

View File

@ -157,6 +157,7 @@ impl State {
ecs.register::<comp::ShockwaveHitEntities>(); ecs.register::<comp::ShockwaveHitEntities>();
ecs.register::<comp::BeamSegment>(); ecs.register::<comp::BeamSegment>();
ecs.register::<comp::Alignment>(); ecs.register::<comp::Alignment>();
ecs.register::<comp::LootOwner>();
// Register components send from clients -> server // Register components send from clients -> server
ecs.register::<comp::Controller>(); ecs.register::<comp::Controller>();

View File

@ -500,13 +500,12 @@ fn handle_drop_all(
server server
.state .state
.create_item_drop(Default::default(), &item) .create_item_drop(Default::default(), item)
.with(comp::Pos(Vec3::new( .with(comp::Pos(Vec3::new(
pos.0.x + rng.gen_range(5.0..10.0), pos.0.x + rng.gen_range(5.0..10.0),
pos.0.y + rng.gen_range(5.0..10.0), pos.0.y + rng.gen_range(5.0..10.0),
pos.0.z + 5.0, pos.0.z + 5.0,
))) )))
.with(item)
.with(comp::Vel(vel)) .with(comp::Vel(vel))
.build(); .build();
} }

View File

@ -3,6 +3,7 @@ use crate::{
comp::{ comp::{
ability, ability,
agent::{Agent, AgentEvent, Sound, SoundKind}, agent::{Agent, AgentEvent, Sound, SoundKind},
loot_owner::LootOwner,
skillset::SkillGroupKind, skillset::SkillGroupKind,
BuffKind, BuffSource, PhysicsState, BuffKind, BuffSource, PhysicsState,
}, },
@ -34,7 +35,8 @@ use common_net::{msg::ServerGeneral, sync::WorldSyncExt};
use common_state::BlockChange; use common_state::BlockChange;
use comp::chat::GenericChatMsg; use comp::chat::GenericChatMsg;
use hashbrown::HashSet; use hashbrown::HashSet;
use rand::Rng; use rand::{distributions::WeightedIndex, Rng};
use rand_distr::Distribution;
use specs::{ use specs::{
join::Join, saveload::MarkerAllocator, Builder, Entity as EcsEntity, Entity, WorldExt, 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 // Award EXP to damage contributors
// //
// NOTE: Debug logging is disabled by default for this module - to enable it add // 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 // Iterate through all contributors of damage for the killed entity, calculating
// how much EXP each contributor should be awarded based on their // how much EXP each contributor should be awarded based on their
// percentage of damage contribution // 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; let contributor_exp = exp_reward * damage_percent;
match damage_contributor { match damage_contributor {
DamageContrib::Solo(attacker) => { DamageContrib::Solo(attacker) => {
@ -365,16 +368,23 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt
None None
} }
} }
}).flatten().for_each(|(attacker, exp_reward)| { }).flatten().collect::<Vec<(Entity, f32)>>();
exp_awards.iter().for_each(|(attacker, exp_reward)| {
// Process the calculated EXP rewards // Process the calculated EXP rewards
if let (Some(mut attacker_skill_set), Some(attacker_uid), Some(attacker_inventory), Some(pos)) = ( if let (
skill_sets.get_mut(attacker), Some(mut attacker_skill_set),
uids.get(attacker), Some(attacker_uid),
inventories.get(attacker), Some(attacker_inventory),
positions.get(attacker), Some(pos),
) = (
skill_sets.get_mut(*attacker),
uids.get(*attacker),
inventories.get(*attacker),
positions.get(*attacker),
) { ) {
handle_exp_gain( handle_exp_gain(
exp_reward, *exp_reward,
attacker_inventory, attacker_inventory,
&mut attacker_skill_set, &mut attacker_skill_set,
attacker_uid, attacker_uid,
@ -437,14 +447,53 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt
let pos = state.ecs().read_storage::<comp::Pos>().get(entity).cloned(); let pos = state.ecs().read_storage::<comp::Pos>().get(entity).cloned();
let vel = state.ecs().read_storage::<comp::Vel>().get(entity).cloned(); let vel = state.ecs().read_storage::<comp::Vel>().get(entity).cloned();
if let Some(pos) = pos { if let Some(pos) = pos {
// TODO: This should only be temporary as you'd eventually want to actually let winner_uid = if exp_awards.is_empty() {
// render the items on the ground, rather than changing the texture depending on None
// the body type } else {
let _ = state // Use the awarded exp per entity as the weight distribution for drop chance
.create_item_drop(comp::Pos(pos.0 + Vec3::unit_z() * 0.25), &item) // 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::<comp::Body>()
.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::<Uid>().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) .maybe_with(vel)
.with(item)
.build(); .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::<comp::LootOwner>()
.insert(item_drop_entity, LootOwner::new(uid))
.unwrap();
}
} else { } else {
error!( error!(
?entity, ?entity,

View File

@ -233,9 +233,8 @@ pub fn handle_mine_block(
} }
} }
state 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(comp::Pos(pos.map(|e| e as f32) + Vec3::new(0.5, 0.5, 0.0)))
.with(item)
.build(); .build();
} }

View File

@ -24,7 +24,10 @@ use comp::LightEmitter;
use crate::{client::Client, Server, StateExt}; use crate::{client::Client, Server, StateExt};
use common::{ use common::{
comp::{pet::is_tameable, ChatType, Group}, comp::{
pet::is_tameable, Alignment, Body, ChatType, CollectFailedReason, Group,
InventoryUpdateEvent, Player,
},
event::{EventBus, ServerEvent}, event::{EventBus, ServerEvent},
}; };
use common_net::msg::ServerGeneral; use common_net::msg::ServerGeneral;
@ -109,14 +112,15 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv
} }
match manip { match manip {
comp::InventoryManip::Pickup(uid) => { comp::InventoryManip::Pickup(pickup_uid) => {
let item_entity = if let Some(item_entity) = state.ecs().entity_from_uid(uid.into()) { let item_entity =
if let Some(item_entity) = state.ecs().entity_from_uid(pickup_uid.into()) {
item_entity item_entity
} else { } else {
// Item entity could not be found - most likely because the entity // 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 // attempted to pick up the same item very quickly before its deletion of the
// world from the first pickup attempt was processed. // world from the first pickup attempt was processed.
debug!("Failed to get entity for item Uid: {}", uid); debug!("Failed to get entity for item Uid: {}", pickup_uid);
return; return;
}; };
let entity_cylinder = get_cylinder(state, entity); let entity_cylinder = get_cylinder(state, entity);
@ -125,11 +129,49 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv
if !within_pickup_range(entity_cylinder, || get_cylinder(state, item_entity)) { if !within_pickup_range(entity_cylinder, || get_cylinder(state, item_entity)) {
debug!( debug!(
?entity_cylinder, ?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; return;
} }
let loot_owner_storage = state.ecs().read_storage::<comp::LootOwner>();
// 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::<comp::LootOwner>()
.get(item_entity)
.map_or(true, |loot_owner| {
let alignments = state.ecs().read_storage::<Alignment>();
let bodies = state.ecs().read_storage::<Body>();
let players = state.ecs().read_storage::<Player>();
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 // 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 // avoid cloning the item, as we should not call Item::clone and it
// may be removed!). // 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 // 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 // attempted to pick up the same item very quickly before its deletion of the
// world from the first pickup attempt was processed. // 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; return;
}; };
@ -165,7 +210,10 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv
); );
drop(item_storage); drop(item_storage);
drop(inventories); drop(inventories);
comp::InventoryUpdate::new(comp::InventoryUpdateEvent::EntityCollectFailed(uid)) comp::InventoryUpdate::new(comp::InventoryUpdateEvent::EntityCollectFailed {
entity: pickup_uid,
reason: comp::CollectFailedReason::InventoryFull,
})
}, },
Ok(_) => { Ok(_) => {
// We succeeded in picking up the item, so we may now delete its old entity // 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(_) => { Err(_) => {
drop_item = Some(item_msg); drop_item = Some(item_msg);
comp::InventoryUpdate::new( 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); drop(inventories);
if let Some(item) = drop_item { if let Some(item) = drop_item {
state state
.create_item_drop(Default::default(), &item) .create_item_drop(Default::default(), item)
.with(comp::Pos( .with(comp::Pos(
Vec3::new(pos.x as f32, pos.y as f32, pos.z as f32) + Vec3::unit_z(), Vec3::new(pos.x as f32, pos.y as f32, pos.z as f32) + Vec3::unit_z(),
)) ))
.with(item)
.with(comp::Vel(Vec3::zero())) .with(comp::Vel(Vec3::zero()))
.build(); .build();
} }
@ -771,9 +821,8 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv
}); });
state 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(comp::Pos(pos.0 + *ori.look_dir() + Vec3::unit_z()))
.with(item)
.with(comp::Vel(Vec3::zero())) .with(comp::Vel(Vec3::zero()))
.build(); .build();
} }

View File

@ -2,14 +2,15 @@
#![allow(clippy::option_map_unit_fn)] #![allow(clippy::option_map_unit_fn)]
#![deny(clippy::clone_on_ref_ptr)] #![deny(clippy::clone_on_ref_ptr)]
#![feature( #![feature(
box_patterns,
label_break_value,
bool_to_option, bool_to_option,
box_patterns,
drain_filter, drain_filter,
label_break_value,
let_chains,
let_else,
never_type, never_type,
option_zip, option_zip,
unwrap_infallible, unwrap_infallible
let_else
)] )]
#![cfg_attr(not(feature = "worldgen"), feature(const_panic))] #![cfg_attr(not(feature = "worldgen"), feature(const_panic))]

View File

@ -55,7 +55,7 @@ pub trait StateExt {
) -> EcsEntityBuilder; ) -> EcsEntityBuilder;
/// Build a static object entity /// Build a static object entity
fn create_object(&mut self, pos: comp::Pos, object: comp::object::Body) -> EcsEntityBuilder; 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<F: FnOnce(comp::ship::Body) -> comp::Collider>( fn create_ship<F: FnOnce(comp::ship::Body) -> comp::Collider>(
&mut self, &mut self,
pos: comp::Pos, pos: comp::Pos,
@ -271,11 +271,12 @@ impl StateExt for State {
.with(body) .with(body)
} }
fn create_item_drop(&mut self, pos: comp::Pos, item: &Item) -> EcsEntityBuilder { fn create_item_drop(&mut self, pos: comp::Pos, item: Item) -> EcsEntityBuilder {
let item_drop = comp::item_drop::Body::from(item); let item_drop = comp::item_drop::Body::from(&item);
let body = comp::Body::ItemDrop(item_drop); let body = comp::Body::ItemDrop(item_drop);
self.ecs_mut() self.ecs_mut()
.create_entity_synced() .create_entity_synced()
.with(item)
.with(pos) .with(pos)
.with(comp::Vel(Vec3::zero())) .with(comp::Vel(Vec3::zero()))
.with(item_drop.orientation(&mut thread_rng())) .with(item_drop.orientation(&mut thread_rng()))

View File

@ -1603,22 +1603,29 @@ impl<'a> AgentData<'a> {
}; };
let is_valid_target = |entity: EcsEntity| match read_data.bodies.get(entity) { let is_valid_target = |entity: EcsEntity| match read_data.bodies.get(entity) {
Some(Body::ItemDrop(item)) => { Some(Body::ItemDrop(item)) => {
//If statement that checks either if the self (agent) is a humanoid, // If there is no LootOwner then the ItemDrop is a valid target, otherwise check
//or if the self is not a humanoid, it checks whether or not you are 'hungry' - // if the loot can be picked up
// meaning less than full health - and additionally checks if read_data
// the target entity is a consumable item. If it qualifies for .loot_owners
// either being a humanoid or a hungry non-humanoid that likes consumables, .get(entity)
// it will choose the item as its target. .map_or(Some((entity, false)), |loot_owner| {
if matches!(self.body, Some(Body::Humanoid(_))) // Agents want to pick up items if they are humanoid, or are hungry and the
|| (self // item is consumable
let hungry = self
.health .health
.map_or(false, |health| health.current() < health.maximum()) .map_or(false, |health| health.current() < health.maximum());
&& matches!(item, item_drop::Body::Consumable)) 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)) Some((entity, false))
} else { } else {
None None
} }
})
}, },
_ => { _ => {
if read_data.healths.get(entity).map_or(false, |health| { if read_data.healths.get(entity).map_or(false, |health| {

View File

@ -2,7 +2,8 @@ use crate::rtsim::Entity as RtSimData;
use common::{ use common::{
comp::{ comp::{
buff::Buffs, group, ActiveAbilities, Alignment, Body, CharacterState, Combo, Energy, 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, link::Is,
mounting::Mount, mounting::Mount,
@ -152,6 +153,7 @@ pub struct ReadData<'a> {
pub buffs: ReadStorage<'a, Buffs>, pub buffs: ReadStorage<'a, Buffs>,
pub combos: ReadStorage<'a, Combo>, pub combos: ReadStorage<'a, Combo>,
pub active_abilities: ReadStorage<'a, ActiveAbilities>, pub active_abilities: ReadStorage<'a, ActiveAbilities>,
pub loot_owners: ReadStorage<'a, LootOwner>,
} }
pub enum Path { pub enum Path {

42
server/src/sys/loot.rs Normal file
View File

@ -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<Self>, (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::<Vec<Entity>>();
if !&expired.is_empty() {
debug!("Removing {} expired loot ownerships", expired.iter().len());
}
for entity in expired {
loot_owners.remove(entity);
}
}
}

View File

@ -3,6 +3,7 @@ pub mod chunk_send;
pub mod chunk_serialize; pub mod chunk_serialize;
pub mod entity_sync; pub mod entity_sync;
pub mod invite_timeout; pub mod invite_timeout;
pub mod loot;
pub mod metrics; pub mod metrics;
pub mod msg; pub mod msg;
pub mod object; pub mod object;

View File

@ -5,7 +5,10 @@ pub mod ping;
pub mod register; pub mod register;
pub mod terrain; pub mod terrain;
use crate::{client::Client, sys::pets}; use crate::{
client::Client,
sys::{loot, pets},
};
use common_ecs::{dispatch, System}; use common_ecs::{dispatch, System};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use specs::DispatcherBuilder; use specs::DispatcherBuilder;
@ -20,6 +23,7 @@ pub fn add_server_systems(dispatch_builder: &mut DispatcherBuilder) {
dispatch::<register::Sys>(dispatch_builder, &[]); dispatch::<register::Sys>(dispatch_builder, &[]);
dispatch::<terrain::Sys>(dispatch_builder, &[]); dispatch::<terrain::Sys>(dispatch_builder, &[]);
dispatch::<pets::Sys>(dispatch_builder, &[]); dispatch::<pets::Sys>(dispatch_builder, &[]);
dispatch::<loot::Sys>(dispatch_builder, &[]);
} }
/// handles all send msg and calls a handle fn /// handles all send msg and calls a handle fn

View File

@ -314,8 +314,8 @@ impl From<&InventoryUpdateEvent> for SfxEvent {
_ => SfxEvent::Inventory(SfxInventoryEvent::Collected), _ => SfxEvent::Inventory(SfxInventoryEvent::Collected),
} }
}, },
InventoryUpdateEvent::BlockCollectFailed(_) InventoryUpdateEvent::BlockCollectFailed { .. }
| InventoryUpdateEvent::EntityCollectFailed(_) => { | InventoryUpdateEvent::EntityCollectFailed { .. } => {
SfxEvent::Inventory(SfxInventoryEvent::CollectFailed) SfxEvent::Inventory(SfxInventoryEvent::CollectFailed)
}, },
InventoryUpdateEvent::Consumed(consumable) => { InventoryUpdateEvent::Consumed(consumable) => {

View File

@ -80,7 +80,7 @@ use common::{
self, self,
ability::AuxiliaryAbility, ability::AuxiliaryAbility,
fluid_dynamics, fluid_dynamics,
inventory::{slot::InvSlotId, trade_pricing::TradePricing}, inventory::{slot::InvSlotId, trade_pricing::TradePricing, CollectFailedReason},
item::{tool::ToolKind, ItemDesc, MaterialStatManifest, Quality}, item::{tool::ToolKind, ItemDesc, MaterialStatManifest, Quality},
pet::is_mountable, pet::is_mountable,
skillset::{skills::Skill, SkillGroupKind}, skillset::{skills::Skill, SkillGroupKind},
@ -997,6 +997,58 @@ pub struct Floaters {
pub block_floaters: Vec<BlockFloater>, pub block_floaters: Vec<BlockFloater>,
} }
#[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::<comp::Stats>()
.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 { pub struct Hud {
ui: Ui, ui: Ui,
ids: Ids, ids: Ids,
@ -1005,8 +1057,8 @@ pub struct Hud {
item_imgs: ItemImgs, item_imgs: ItemImgs,
fonts: Fonts, fonts: Fonts,
rot_imgs: ImgsRot, rot_imgs: ImgsRot,
failed_block_pickups: HashMap<Vec3<i32>, f32>, failed_block_pickups: HashMap<Vec3<i32>, CollectFailedData>,
failed_entity_pickups: HashMap<EcsEntity, f32>, failed_entity_pickups: HashMap<EcsEntity, CollectFailedData>,
new_loot_messages: VecDeque<LootMessage>, new_loot_messages: VecDeque<LootMessage>,
new_messages: VecDeque<comp::ChatMsg>, new_messages: VecDeque<comp::ChatMsg>,
new_notifications: VecDeque<Notification>, new_notifications: VecDeque<Notification>,
@ -1772,9 +1824,9 @@ impl Hud {
}; };
self.failed_block_pickups 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 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. // Render overitem: name, etc.
for (entity, pos, item, distance) in (&entities, &pos, &items) for (entity, pos, item, distance) in (&entities, &pos, &items)
@ -1793,7 +1845,7 @@ impl Hud {
distance, distance,
overitem::OveritemProperties { overitem::OveritemProperties {
active: interactable.as_ref().and_then(|i| i.entity()) == Some(entity), 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, &self.fonts,
vec![(GameInput::Interact, i18n.get("hud.pick_up").to_string())], vec![(GameInput::Interact, i18n.get("hud.pick_up").to_string())],
@ -1810,7 +1862,7 @@ impl Hud {
let overitem_properties = overitem::OveritemProperties { let overitem_properties = overitem::OveritemProperties {
active: true, 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 pos = pos.map(|e| e as f32 + 0.5);
let over_pos = pos + Vec3::unit_z() * 0.7; 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<i32>) { pub fn add_failed_block_pickup(&mut self, pos: Vec3<i32>, reason: HudCollectFailedReason) {
self.failed_block_pickups.insert(pos, self.pulse); self.failed_block_pickups
.insert(pos, CollectFailedData::new(self.pulse, reason));
} }
pub fn add_failed_entity_pickup(&mut self, entity: EcsEntity) { pub fn add_failed_entity_pickup(&mut self, entity: EcsEntity, reason: HudCollectFailedReason) {
self.failed_entity_pickups.insert(entity, self.pulse); self.failed_entity_pickups
.insert(entity, CollectFailedData::new(self.pulse, reason));
} }
pub fn new_loot_message(&mut self, item: LootMessage) { pub fn new_loot_message(&mut self, item: LootMessage) {

View File

@ -11,6 +11,7 @@ use conrod_core::{
use i18n::Localization; use i18n::Localization;
use std::borrow::Cow; use std::borrow::Cow;
use crate::hud::{CollectFailedData, HudCollectFailedReason, HudLootOwner};
use keyboard_keynames::key_layout::KeyLayout; use keyboard_keynames::key_layout::KeyLayout;
pub const TEXT_COLOR: Color = Color::Rgba(0.61, 0.61, 0.89, 1.0); 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 struct OveritemProperties {
pub active: bool, pub active: bool,
pub pickup_failed_pulse: Option<f32>, pub pickup_failed_pulse: Option<CollectFailedData>,
} }
pub struct State { pub struct State {
@ -215,9 +216,10 @@ impl<'a> Widget for Overitem<'a> {
.parent(id) .parent(id)
.set(state.ids.btn_bg, ui); .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 //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 alpha = 1.0 - age.powi(4);
let brightness = 1.0 / (age / 0.07 - 1.0).abs().clamp(0.01, 1.0); 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) 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_id(self.fonts.cyri.conrod_id)
.font_size(inv_full_font_size as u32) .font_size(inv_full_font_size as u32)
.color(shade_color(Color::Rgba(0.0, 0.0, 0.0, 1.0))) .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) .depth(self.distance_from_player_sqr + 6.0)
.set(state.ids.inv_full_bg, ui); .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_id(self.fonts.cyri.conrod_id)
.font_size(inv_full_font_size as u32) .font_size(inv_full_font_size as u32)
.color(shade_color(Color::Rgba(1.0, 0.0, 0.0, 1.0))) .color(shade_color(Color::Rgba(1.0, 0.0, 0.0, 1.0)))

View File

@ -43,7 +43,10 @@ use crate::{
audio::sfx::SfxEvent, audio::sfx::SfxEvent,
error::Error, error::Error,
game_input::GameInput, 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, key_state::KeyState,
menu::char_selection::CharSelectionState, menu::char_selection::CharSelectionState,
render::{Drawer, GlobalsBindGroup}, render::{Drawer, GlobalsBindGroup},
@ -244,12 +247,27 @@ impl SessionState {
global_state.audio.emit_sfx_item(sfx_trigger_item); global_state.audio.emit_sfx_item(sfx_trigger_item);
match inv_event { match inv_event {
InventoryUpdateEvent::BlockCollectFailed(pos) => { InventoryUpdateEvent::BlockCollectFailed { pos, reason } => {
self.hud.add_failed_block_pickup(pos); 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()) { 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) => { InventoryUpdateEvent::Collected(item) => {