From 01ca6911a94684b768ff0db64840eb81e3aa28fc Mon Sep 17 00:00:00 2001 From: Ben Wallis Date: Wed, 28 Jul 2021 22:36:41 +0000 Subject: [PATCH] * Pets are now saved on logout and spawned with the player on login * Pets now teleport to their owner when they are too far away from them * Limited the animals that can be tamed to `QuadrupedLow` and `QuadrupedSmall` to prevent players taming overly powerful creatures before the pet feature is further developed * Added `Pet` component used to store pet information about an entity - currently only used to store the pet's database ID * Added `pet` database table which stores a pet's `body_id` and `name`, alongside the `character_id` that it belongs to * Replaced `HomeChunk` component with more flexible `Anchor` component which supports anchoring entities to other entities as well as chunks. --- CHANGELOG.md | 8 +- common/src/comp/anchor.rs | 20 ++ common/src/comp/body/quadruped_low.rs | 13 +- common/src/comp/body/quadruped_medium.rs | 11 +- common/src/comp/body/quadruped_small.rs | 11 +- common/src/comp/home_chunk.rs | 13 -- common/src/comp/mod.rs | 8 +- common/src/comp/pet.rs | 51 +++++ common/src/event.rs | 8 +- common/src/states/basic_summon.rs | 2 +- server/src/character_creator.rs | 2 +- server/src/cmd.rs | 31 +-- server/src/events/entity_creation.rs | 10 +- server/src/events/interaction.rs | 7 + server/src/events/inventory_manip.rs | 64 ++---- server/src/events/mod.rs | 8 +- server/src/events/player.rs | 35 ++- server/src/lib.rs | 53 ++++- server/src/migrations/V43__pets.sql | 10 + server/src/persistence/character.rs | 201 +++++++++++++++++- .../src/persistence/character/conversions.rs | 91 ++++++-- server/src/persistence/character_updater.rs | 30 ++- server/src/persistence/json_models.rs | 27 +++ server/src/persistence/mod.rs | 2 + server/src/persistence/models.rs | 7 + server/src/pet.rs | 77 +++++++ server/src/rtsim/tick.rs | 2 +- server/src/state_ext.rs | 39 +++- server/src/sys/mod.rs | 1 + server/src/sys/msg/mod.rs | 3 +- server/src/sys/persistence.rs | 43 +++- server/src/sys/pets.rs | 75 +++++++ server/src/sys/terrain.rs | 2 +- 33 files changed, 808 insertions(+), 157 deletions(-) create mode 100644 common/src/comp/anchor.rs delete mode 100644 common/src/comp/home_chunk.rs create mode 100644 common/src/comp/pet.rs create mode 100644 server/src/migrations/V43__pets.sql create mode 100644 server/src/pet.rs create mode 100644 server/src/sys/pets.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e4c1fbe4de..4845b4b3ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - HUD debug info now displays current biome and site - Quotes and escape codes can be used in command arguments - Toggle chat with a shortcut (default is F5) +- Pets are now saved on logout 🐕 🦎 🐼 ### Changed @@ -34,15 +35,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Harvester boss now has new abilities and AI - Death particles and SFX - Default keybindings were made more consistent -- Adjust Yeti difficulty +- Adjusted Yeti difficulty - Now most of the food gives Saturation in the process of eating - Mushroom Curry gives long-lasting Regeneration buff - Trades now consider if items can stack in full inventories. +- The types of animals that can be tamed as pets are now limited to certain species, pending further balancing of pets ### Removed -- Enemies no more spawn in dungeon boss room -- Melee critical hit no more applies after reduction by armour +- Enemies no longer spawn in dungeon boss room +- Melee critical hit no longer applies after reduction by armour ### Fixed diff --git a/common/src/comp/anchor.rs b/common/src/comp/anchor.rs new file mode 100644 index 0000000000..07fc601fcf --- /dev/null +++ b/common/src/comp/anchor.rs @@ -0,0 +1,20 @@ +use specs::{Component, Entity}; +use specs_idvs::IdvStorage; +use vek::Vec2; + +/// This component exists in order to fix a bug that caused entities +/// such as campfires to duplicate because the chunk was double-loaded. +/// See https://gitlab.com/veloren/veloren/-/merge_requests/1543 +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Anchor { + /// An entity with an Entity Anchor will be destroyed when its anchor Entity + /// no longer exists + Entity(Entity), + /// An entity with Chunk Anchor will be destroyed when both the chunk it's + /// currently positioned within and its anchor chunk are unloaded + Chunk(Vec2), +} + +impl Component for Anchor { + type Storage = IdvStorage; +} diff --git a/common/src/comp/body/quadruped_low.rs b/common/src/comp/body/quadruped_low.rs index f2c14091e2..9b39604c0a 100644 --- a/common/src/comp/body/quadruped_low.rs +++ b/common/src/comp/body/quadruped_low.rs @@ -1,6 +1,7 @@ use crate::{make_case_elim, make_proj_elim}; use rand::{seq::SliceRandom, thread_rng}; use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; make_proj_elim!( body, @@ -29,9 +30,13 @@ impl From for super::Body { fn from(body: Body) -> Self { super::Body::QuadrupedLow(body) } } +// Renaming any enum entries here (re-ordering is fine) will require a +// database migration to ensure pets correctly de-serialize on player login. make_case_elim!( species, - #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[derive( + Copy, Clone, Debug, Display, EnumString, PartialEq, Eq, Hash, Serialize, Deserialize, + )] #[repr(u32)] pub enum Species { Crocodile = 0, @@ -120,9 +125,13 @@ impl<'a, SpeciesMeta: 'a> IntoIterator for &'a AllSpecies { fn into_iter(self) -> Self::IntoIter { ALL_SPECIES.iter().copied() } } +// Renaming any enum entries here (re-ordering is fine) will require a +// database migration to ensure pets correctly de-serialize on player login. make_case_elim!( body_type, - #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[derive( + Copy, Clone, Debug, Display, EnumString, PartialEq, Eq, Hash, Serialize, Deserialize, + )] #[repr(u32)] pub enum BodyType { Female = 0, diff --git a/common/src/comp/body/quadruped_medium.rs b/common/src/comp/body/quadruped_medium.rs index 04efc68c25..7f8d36ea0f 100644 --- a/common/src/comp/body/quadruped_medium.rs +++ b/common/src/comp/body/quadruped_medium.rs @@ -1,6 +1,7 @@ use crate::{make_case_elim, make_proj_elim}; use rand::{seq::SliceRandom, thread_rng}; use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; make_proj_elim!( body, @@ -29,7 +30,9 @@ impl From for super::Body { fn from(body: Body) -> Self { super::Body::QuadrupedMedium(body) } } -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +// Renaming any enum entries here (re-ordering is fine) will require a +// database migration to ensure pets correctly de-serialize on player login. +#[derive(Copy, Clone, Debug, Display, EnumString, PartialEq, Eq, Hash, Serialize, Deserialize)] #[repr(u32)] pub enum Species { Grolgar = 0, @@ -197,9 +200,13 @@ impl<'a, SpeciesMeta: 'a> IntoIterator for &'a AllSpecies { fn into_iter(self) -> Self::IntoIter { ALL_SPECIES.iter().copied() } } +// Renaming any enum entries here (re-ordering is fine) will require a +// database migration to ensure pets correctly de-serialize on player login. make_case_elim!( body_type, - #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[derive( + Copy, Clone, Debug, Display, EnumString, PartialEq, Eq, Hash, Serialize, Deserialize, + )] #[repr(u32)] pub enum BodyType { Female = 0, diff --git a/common/src/comp/body/quadruped_small.rs b/common/src/comp/body/quadruped_small.rs index 1d4f294e64..75a5428283 100644 --- a/common/src/comp/body/quadruped_small.rs +++ b/common/src/comp/body/quadruped_small.rs @@ -1,6 +1,7 @@ use crate::{make_case_elim, make_proj_elim}; use rand::{seq::SliceRandom, thread_rng}; use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; make_proj_elim!( body, @@ -29,7 +30,9 @@ impl From for super::Body { fn from(body: Body) -> Self { super::Body::QuadrupedSmall(body) } } -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +// Renaming any enum entries here (re-ordering is fine) will require a +// database migration to ensure pets correctly de-serialize on player login. +#[derive(Copy, Clone, Debug, Display, EnumString, PartialEq, Eq, Hash, Serialize, Deserialize)] #[repr(u32)] pub enum Species { Pig = 0, @@ -169,9 +172,13 @@ impl<'a, SpeciesMeta: 'a> IntoIterator for &'a AllSpecies { fn into_iter(self) -> Self::IntoIter { ALL_SPECIES.iter().copied() } } +// Renaming any enum entries here (re-ordering is fine) will require a +// database migration to ensure pets correctly de-serialize on player login. make_case_elim!( body_type, - #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[derive( + Copy, Clone, Debug, Display, EnumString, PartialEq, Eq, Hash, Serialize, Deserialize, + )] #[repr(u32)] pub enum BodyType { Female = 0, diff --git a/common/src/comp/home_chunk.rs b/common/src/comp/home_chunk.rs deleted file mode 100644 index 0389d2815a..0000000000 --- a/common/src/comp/home_chunk.rs +++ /dev/null @@ -1,13 +0,0 @@ -use specs::Component; -use specs_idvs::IdvStorage; -use vek::Vec2; - -/// This component exists in order to fix a bug that caused entitites -/// such as campfires to duplicate because the chunk was double-loaded. -/// See https://gitlab.com/veloren/veloren/-/merge_requests/1543 -#[derive(Copy, Clone, Default, Debug, PartialEq)] -pub struct HomeChunk(pub Vec2); - -impl Component for HomeChunk { - type Storage = IdvStorage; -} diff --git a/common/src/comp/mod.rs b/common/src/comp/mod.rs index 95358bce75..2ff0d41209 100644 --- a/common/src/comp/mod.rs +++ b/common/src/comp/mod.rs @@ -1,6 +1,8 @@ #[cfg(not(target_arch = "wasm32"))] mod ability; #[cfg(not(target_arch = "wasm32"))] mod admin; #[cfg(not(target_arch = "wasm32"))] pub mod agent; +#[cfg(not(target_arch = "wasm32"))] +pub mod anchor; #[cfg(not(target_arch = "wasm32"))] pub mod aura; #[cfg(not(target_arch = "wasm32"))] pub mod beam; #[cfg(not(target_arch = "wasm32"))] pub mod body; @@ -19,8 +21,6 @@ pub mod dialogue; pub mod fluid_dynamics; #[cfg(not(target_arch = "wasm32"))] pub mod group; mod health; -#[cfg(not(target_arch = "wasm32"))] -pub mod home_chunk; #[cfg(not(target_arch = "wasm32"))] mod inputs; #[cfg(not(target_arch = "wasm32"))] pub mod inventory; @@ -30,6 +30,7 @@ pub mod invite; #[cfg(not(target_arch = "wasm32"))] mod location; #[cfg(not(target_arch = "wasm32"))] mod misc; #[cfg(not(target_arch = "wasm32"))] pub mod ori; +#[cfg(not(target_arch = "wasm32"))] pub mod pet; #[cfg(not(target_arch = "wasm32"))] mod phys; #[cfg(not(target_arch = "wasm32"))] mod player; #[cfg(not(target_arch = "wasm32"))] pub mod poise; @@ -49,6 +50,7 @@ pub use self::{ ability::{CharacterAbility, CharacterAbilityType}, admin::{Admin, AdminRole}, agent::{Agent, Alignment, Behavior, BehaviorCapability, BehaviorState, PidController}, + anchor::Anchor, aura::{Aura, AuraChange, AuraKind, Auras}, beam::{Beam, BeamSegment}, body::{ @@ -73,7 +75,6 @@ pub use self::{ energy::{Energy, EnergyChange, EnergySource}, fluid_dynamics::Fluid, group::Group, - home_chunk::HomeChunk, inputs::CanBuild, inventory::{ item::{self, tool, Item, ItemConfig, ItemDrop}, @@ -83,6 +84,7 @@ pub use self::{ location::{Waypoint, WaypointArea}, misc::Object, ori::Ori, + pet::Pet, phys::{ Collider, Density, ForceUpdate, Mass, PhysicsState, Pos, PosVelOriDefer, PreviousPhysCache, Scale, Sticky, Vel, diff --git a/common/src/comp/pet.rs b/common/src/comp/pet.rs new file mode 100644 index 0000000000..78187c59cb --- /dev/null +++ b/common/src/comp/pet.rs @@ -0,0 +1,51 @@ +use crate::comp::body::Body; +use crossbeam_utils::atomic::AtomicCell; +use serde::{Deserialize, Serialize}; +use specs::Component; +use specs_idvs::IdvStorage; +use std::{num::NonZeroU64, sync::Arc}; + +pub type PetId = AtomicCell>; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Pet { + #[serde(skip)] + database_id: Arc, +} + +impl Pet { + /// Not to be used outside of persistence - provides mutable access to the + /// pet component's database ID which is required to save the pet's data + /// from the persistence thread. + #[doc(hidden)] + pub fn get_database_id(&self) -> Arc { Arc::clone(&self.database_id) } + + pub fn new_from_database(database_id: NonZeroU64) -> Self { + Self { + database_id: Arc::new(AtomicCell::new(Some(database_id))), + } + } +} + +impl Default for Pet { + fn default() -> Self { + Self { + database_id: Arc::new(AtomicCell::new(None)), + } + } +} + +/// Determines whether an entity of a particular body variant is tameable. +pub fn is_tameable(body: &Body) -> bool { + // Currently only Quadruped animals can be tamed pending further work + // on the pets feature (allowing larger animals to be tamed will + // require balance issues to be addressed). + matches!( + body, + Body::QuadrupedLow(_) | Body::QuadrupedMedium(_) | Body::QuadrupedSmall(_) + ) +} + +impl Component for Pet { + type Storage = IdvStorage; +} diff --git a/common/src/event.rs b/common/src/event.rs index 617726765c..1d48bac86b 100644 --- a/common/src/event.rs +++ b/common/src/event.rs @@ -102,12 +102,14 @@ pub enum ServerEvent { }, UpdateCharacterData { entity: EcsEntity, + #[allow(clippy::type_complexity)] components: ( comp::Body, comp::Stats, comp::SkillSet, comp::Inventory, Option, + Vec<(comp::Pet, comp::Body, comp::Stats)>, ), }, ExitIngame { @@ -125,7 +127,7 @@ pub enum ServerEvent { agent: Option, alignment: comp::Alignment, scale: comp::Scale, - home_chunk: Option, + anchor: Option, drop_item: Option, rtsim_entity: Option, projectile: Option, @@ -186,6 +188,10 @@ pub enum ServerEvent { pos: Vec3, sprite: SpriteKind, }, + TamePet { + pet_entity: EcsEntity, + owner_entity: EcsEntity, + }, } pub struct EventBus { diff --git a/common/src/states/basic_summon.rs b/common/src/states/basic_summon.rs index 80eab6e301..22487c1026 100644 --- a/common/src/states/basic_summon.rs +++ b/common/src/states/basic_summon.rs @@ -186,7 +186,7 @@ impl CharacterBehavior for Data { .summon_info .scale .unwrap_or(comp::Scale(1.0)), - home_chunk: None, + anchor: None, drop_item: None, rtsim_entity: None, projectile, diff --git a/server/src/character_creator.rs b/server/src/character_creator.rs index fd9b896b7e..c7b5b50edc 100644 --- a/server/src/character_creator.rs +++ b/server/src/character_creator.rs @@ -59,6 +59,6 @@ pub fn create_character( entity, player_uuid, character_alias, - (body, stats, skill_set, inventory, waypoint), + (body, stats, skill_set, inventory, waypoint, Vec::new()), ); } diff --git a/server/src/cmd.rs b/server/src/cmd.rs index 0a6c5c33b1..2a7bb6f0a4 100644 --- a/server/src/cmd.rs +++ b/server/src/cmd.rs @@ -1045,31 +1045,12 @@ fn handle_spawn( // Add to group system if a pet if matches!(alignment, comp::Alignment::Owned { .. }) { - let state = server.state(); - let clients = state.ecs().read_storage::(); - let uids = state.ecs().read_storage::(); - let mut group_manager = - state.ecs().write_resource::(); - group_manager.new_pet( - new_entity, - target, - &mut state.ecs().write_storage(), - &state.ecs().entities(), - &state.ecs().read_storage(), - &uids, - &mut |entity, group_change| { - clients - .get(entity) - .and_then(|c| { - group_change - .try_map(|e| uids.get(e).copied()) - .map(|g| (g, c)) - }) - .map(|(g, c)| { - c.send_fallible(ServerGeneral::GroupUpdate(g)); - }); - }, - ); + let server_eventbus = + server.state.ecs().read_resource::>(); + server_eventbus.emit_now(ServerEvent::TamePet { + owner_entity: target, + pet_entity: new_entity, + }); } else if let Some(group) = match alignment { comp::Alignment::Wild => None, comp::Alignment::Passive => None, diff --git a/server/src/events/entity_creation.rs b/server/src/events/entity_creation.rs index 8112123c96..c0000b2024 100644 --- a/server/src/events/entity_creation.rs +++ b/server/src/events/entity_creation.rs @@ -8,9 +8,9 @@ use common::{ beam, buff::{BuffCategory, BuffData, BuffKind, BuffSource}, inventory::loadout::Loadout, - shockwave, Agent, Alignment, Body, Health, HomeChunk, Inventory, Item, ItemDrop, - LightEmitter, Object, Ori, PidController, Poise, Pos, Projectile, Scale, SkillSet, Stats, - Vel, WaypointArea, + shockwave, Agent, Alignment, Anchor, Body, Health, Inventory, Item, ItemDrop, LightEmitter, + Object, Ori, PidController, Poise, Pos, Projectile, Scale, SkillSet, Stats, Vel, + WaypointArea, }, outcome::Outcome, rtsim::RtSimEntity, @@ -30,6 +30,7 @@ pub fn handle_initialize_character( server.state.initialize_character_data(entity, character_id); } +#[allow(clippy::type_complexity)] pub fn handle_loaded_character_data( server: &mut Server, entity: EcsEntity, @@ -39,6 +40,7 @@ pub fn handle_loaded_character_data( comp::SkillSet, comp::Inventory, Option, + Vec<(comp::Pet, comp::Body, comp::Stats)>, ), ) { server @@ -61,7 +63,7 @@ pub fn handle_create_npc( alignment: Alignment, scale: Scale, drop_item: Option, - home_chunk: Option, + home_chunk: Option, rtsim_entity: Option, projectile: Option, ) { diff --git a/server/src/events/interaction.rs b/server/src/events/interaction.rs index 3ec0cf10a2..11f1942349 100644 --- a/server/src/events/interaction.rs +++ b/server/src/events/interaction.rs @@ -29,6 +29,7 @@ use crate::{ Server, }; +use crate::pet::tame_pet; use hashbrown::{HashMap, HashSet}; use lazy_static::lazy_static; use serde::Deserialize; @@ -442,3 +443,9 @@ pub fn handle_create_sprite(server: &mut Server, pos: Vec3, sprite: SpriteK } } } + +pub fn handle_tame_pet(server: &mut Server, pet_entity: EcsEntity, owner_entity: EcsEntity) { + // TODO: Raise outcome to send to clients to play sound/render an indicator + // showing taming success? + tame_pet(server.state.ecs(), pet_entity, owner_entity); +} diff --git a/server/src/events/inventory_manip.rs b/server/src/events/inventory_manip.rs index b6cb9b5dbe..d4e54792a6 100644 --- a/server/src/events/inventory_manip.rs +++ b/server/src/events/inventory_manip.rs @@ -16,11 +16,15 @@ use common::{ util::find_dist::{self, FindDist}, vol::ReadVol, }; -use common_net::{msg::ServerGeneral, sync::WorldSyncExt}; +use common_net::sync::WorldSyncExt; use common_state::State; use comp::LightEmitter; -use crate::{client::Client, Server, StateExt}; +use crate::{Server, StateExt}; +use common::{ + comp::pet::is_tameable, + event::{EventBus, ServerEvent}, +}; pub fn swap_lantern( storage: &mut WriteStorage, @@ -310,6 +314,7 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv kind: comp::item::Utility::Collar, .. } => { + const MAX_PETS: usize = 3; let reinsert = if let Some(pos) = state.read_storage::().get(entity) { @@ -322,65 +327,36 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv alignment == &&comp::Alignment::Owned(uid) }) .count() - >= 3 + >= MAX_PETS { true } else if let Some(tameable_entity) = { let nearest_tameable = ( &state.ecs().entities(), + &state.ecs().read_storage::(), &state.ecs().read_storage::(), &state.ecs().read_storage::(), ) .join() - .filter(|(_, wild_pos, _)| { + .filter(|(_, _, wild_pos, _)| { wild_pos.0.distance_squared(pos.0) < 5.0f32.powi(2) }) - .filter(|(_, _, alignment)| { + .filter(|(_, body, _, alignment)| { alignment == &&comp::Alignment::Wild + && is_tameable(body) }) - .min_by_key(|(_, wild_pos, _)| { + .min_by_key(|(_, _, wild_pos, _)| { (wild_pos.0.distance_squared(pos.0) * 100.0) as i32 }) - .map(|(entity, _, _)| entity); + .map(|(entity, _, _, _)| entity); nearest_tameable } { - let _ = state - .ecs() - .write_storage() - .insert(tameable_entity, comp::Alignment::Owned(uid)); - - // Add to group system - let clients = state.ecs().read_storage::(); - let uids = state.ecs().read_storage::(); - let mut group_manager = state - .ecs() - .write_resource::( - ); - group_manager.new_pet( - tameable_entity, - entity, - &mut state.ecs().write_storage(), - &state.ecs().entities(), - &state.ecs().read_storage(), - &uids, - &mut |entity, group_change| { - clients - .get(entity) - .and_then(|c| { - group_change - .try_map(|e| uids.get(e).copied()) - .map(|g| (g, c)) - }) - .map(|(g, c)| { - c.send(ServerGeneral::GroupUpdate(g)) - }); - }, - ); - - let _ = state - .ecs() - .write_storage() - .insert(tameable_entity, comp::Agent::default()); + let server_eventbus = + state.ecs().read_resource::>(); + server_eventbus.emit_now(ServerEvent::TamePet { + owner_entity: entity, + pet_entity: tameable_entity, + }); false } else { true diff --git a/server/src/events/mod.rs b/server/src/events/mod.rs index 0f582678f6..af3d2baeed 100644 --- a/server/src/events/mod.rs +++ b/server/src/events/mod.rs @@ -1,4 +1,4 @@ -use crate::{state_ext::StateExt, Server}; +use crate::{events::interaction::handle_tame_pet, state_ext::StateExt, Server}; use common::event::{EventBus, ServerEvent}; use common_base::span; use entity_creation::{ @@ -144,7 +144,7 @@ impl Server { agent, alignment, scale, - home_chunk, + anchor: home_chunk, drop_item, rtsim_entity, projectile, @@ -224,6 +224,10 @@ impl Server { ServerEvent::CreateSprite { pos, sprite } => { handle_create_sprite(self, pos, sprite) }, + ServerEvent::TamePet { + pet_entity, + owner_entity, + } => handle_tame_pet(self, pet_entity, owner_entity), } } diff --git a/server/src/events/player.rs b/server/src/events/player.rs index 2152265f78..e7290c33c9 100644 --- a/server/src/events/player.rs +++ b/server/src/events/player.rs @@ -5,13 +5,13 @@ use crate::{ }; use common::{ comp, - comp::group, + comp::{group, pet::is_tameable}, uid::{Uid, UidAllocator}, }; use common_base::span; use common_net::msg::{PlayerListUpdate, PresenceKind, ServerGeneral}; use common_state::State; -use specs::{saveload::MarkerAllocator, Builder, Entity as EcsEntity, WorldExt}; +use specs::{saveload::MarkerAllocator, Builder, Entity as EcsEntity, Join, WorldExt}; use tracing::{debug, error, trace, warn, Instrument}; pub fn handle_exit_ingame(server: &mut Server, entity: EcsEntity) { @@ -195,10 +195,17 @@ pub fn handle_client_disconnect( // the race condition of their login fetching their old data // and overwriting the data saved here. fn persist_entity(state: &mut State, entity: EcsEntity) -> EcsEntity { - if let (Some(presence), Some(skill_set), Some(inventory), mut character_updater) = ( + if let ( + Some(presence), + Some(skill_set), + Some(inventory), + Some(player_uid), + mut character_updater, + ) = ( state.read_storage::().get(entity), state.read_storage::().get(entity), state.read_storage::().get(entity), + state.read_storage::().get(entity), state.ecs().fetch_mut::(), ) { match presence.kind { @@ -209,9 +216,29 @@ fn persist_entity(state: &mut State, entity: EcsEntity) -> EcsEntity { .get(entity) .cloned(); + // Get player's pets + let alignments = state.ecs().read_storage::(); + let bodies = state.ecs().read_storage::(); + let stats = state.ecs().read_storage::(); + let pets = state.ecs().read_storage::(); + let pets = (&alignments, &bodies, &stats, &pets) + .join() + .filter_map(|(alignment, body, stats, pet)| match alignment { + // Don't try to persist non-tameable pets (likely spawned + // using /spawn) since there isn't any code to handle + // persisting them + common::comp::Alignment::Owned(ref pet_owner) + if pet_owner == player_uid && is_tameable(body) => + { + Some(((*pet).clone(), *body, stats.clone())) + }, + _ => None, + }) + .collect(); + character_updater.add_pending_logout_update( char_id, - (skill_set.clone(), inventory.clone(), waypoint), + (skill_set.clone(), inventory.clone(), pets, waypoint), ); }, PresenceKind::Spectator => { /* Do nothing, spectators do not need persisting */ }, diff --git a/server/src/lib.rs b/server/src/lib.rs index 1603fd2a9c..b70cc13247 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -25,6 +25,7 @@ pub mod input; pub mod login_provider; pub mod metrics; pub mod persistence; +mod pet; pub mod presence; pub mod rtsim; pub mod settings; @@ -87,7 +88,7 @@ use persistence::{ }; use prometheus::Registry; use prometheus_hyper::Server as PrometheusServer; -use specs::{join::Join, Builder, Entity as EcsEntity, SystemData, WorldExt}; +use specs::{join::Join, Builder, Entity as EcsEntity, Entity, SystemData, WorldExt}; use std::{ i32, ops::{Deref, DerefMut}, @@ -112,6 +113,7 @@ use { common_state::plugin::{memory_manager::EcsWorld, PluginMgr}, }; +use common::comp::Anchor; #[cfg(feature = "worldgen")] use world::{ sim::{FileOpts, WorldOpts, DEFAULT_WORLD_MAP}, @@ -249,7 +251,8 @@ impl Server { state.ecs_mut().register::(); state.ecs_mut().register::(); state.ecs_mut().register::(); - state.ecs_mut().register::(); + state.ecs_mut().register::(); + state.ecs_mut().register::(); state.ecs_mut().register::(); state.ecs_mut().register::(); @@ -617,6 +620,32 @@ impl Server { run_now::(self.state.ecs_mut()); } + // Prevent anchor entity chains which are not currently supported + let anchors = self.state.ecs().read_storage::(); + let anchored_anchor_entities: Vec = ( + &self.state.ecs().entities(), + &self.state.ecs().read_storage::(), + ) + .join() + .filter_map(|(_, anchor)| match anchor { + Anchor::Entity(anchor_entity) => Some(*anchor_entity), + _ => None, + }) + .filter(|anchor_entity| anchors.get(*anchor_entity).is_some()) + .collect(); + drop(anchors); + + for entity in anchored_anchor_entities { + if cfg!(debug_assertions) { + panic!("Entity anchor chain detected"); + } + error!( + "Detected an anchor entity that itself has an anchor entity - anchor chains are \ + not currently supported. The entity's Anchor component has been deleted" + ); + self.state.delete_component::(entity); + } + // Remove NPCs that are outside the view distances of all players // This is done by removing NPCs in unloaded chunks let to_delete = { @@ -625,16 +654,22 @@ impl Server { &self.state.ecs().entities(), &self.state.ecs().read_storage::(), !&self.state.ecs().read_storage::(), - self.state.ecs().read_storage::().maybe(), + self.state.ecs().read_storage::().maybe(), ) .join() - .filter(|(_, pos, _, home_chunk)| { + .filter(|(_, pos, _, anchor)| { let chunk_key = terrain.pos_key(pos.0.map(|e| e.floor() as i32)); - // Check if both this chunk and the NPCs `home_chunk` is unloaded. If so, - // we delete them. We check for `home_chunk` in order to avoid duplicating - // the entity under some circumstances. - terrain.get_key(chunk_key).is_none() - && home_chunk.map_or(true, |hc| terrain.get_key(hc.0).is_none()) + match anchor { + Some(Anchor::Chunk(hc)) => { + // Check if both this chunk and the NPCs `home_chunk` is unloaded. If + // so, we delete them. We check for + // `home_chunk` in order to avoid duplicating + // the entity under some circumstances. + terrain.get_key(chunk_key).is_none() && terrain.get_key(*hc).is_none() + }, + Some(Anchor::Entity(entity)) => !self.state.ecs().is_alive(*entity), + None => terrain.get_key(chunk_key).is_none(), + } }) .map(|(entity, _, _, _)| entity) .collect::>() diff --git a/server/src/migrations/V43__pets.sql b/server/src/migrations/V43__pets.sql new file mode 100644 index 0000000000..276531887a --- /dev/null +++ b/server/src/migrations/V43__pets.sql @@ -0,0 +1,10 @@ +-- Creates new pet table +CREATE TABLE "pet" ( + "pet_id" INT NOT NULL, + "character_id" INT NOT NULL, + "name" TEXT NOT NULL, + PRIMARY KEY("pet_id"), + FOREIGN KEY("pet_id") REFERENCES entity(entity_id), + FOREIGN KEY("character_id") REFERENCES "character"("character_id") +); + diff --git a/server/src/persistence/character.rs b/server/src/persistence/character.rs index 34fd00022c..1b19ccf18b 100644 --- a/server/src/persistence/character.rs +++ b/server/src/persistence/character.rs @@ -20,6 +20,7 @@ use crate::{ convert_waypoint_from_database_json, convert_waypoint_to_database_json, }, character_loader::{CharacterCreationResult, CharacterDataResult, CharacterListResult}, + character_updater::PetPersistenceData, error::PersistenceError::DatabaseError, PersistedComponents, }, @@ -27,8 +28,8 @@ use crate::{ use common::character::{CharacterId, CharacterItem, MAX_CHARACTERS_PER_PLAYER}; use core::ops::Range; use rusqlite::{types::Value, Connection, ToSql, Transaction, NO_PARAMS}; -use std::rc::Rc; -use tracing::{error, trace, warn}; +use std::{num::NonZeroU64, rc::Rc}; +use tracing::{debug, error, trace, warn}; /// Private module for very tightly coupled database conversion methods. In /// general, these have many invariants that need to be maintained when they're @@ -203,8 +204,55 @@ pub fn load_character_data( .filter_map(Result::ok) .collect::>(); + #[rustfmt::skip] + let mut stmt = connection.prepare_cached(" + SELECT p.pet_id, + p.name, + b.variant, + b.body_data + FROM pet p + JOIN body b ON (p.pet_id = b.body_id) + WHERE p.character_id = ?1", + )?; + + let db_pets = stmt + .query_map(&[char_id], |row| { + Ok(Pet { + database_id: row.get(0)?, + name: row.get(1)?, + body_variant: row.get(2)?, + body_data: row.get(3)?, + }) + })? + .filter_map(Result::ok) + .collect::>(); + + // Re-construct the pet components for the player's pets, including + // de-serializing the pets' bodies and creating their Pet and Stats + // components + let pets = db_pets + .iter() + .filter_map(|db_pet| { + if let Ok(pet_body) = + convert_body_from_database(&db_pet.body_variant, &db_pet.body_data) + { + let pet = comp::Pet::new_from_database( + NonZeroU64::new(db_pet.database_id as u64).unwrap(), + ); + let pet_stats = comp::Stats::new(db_pet.name.to_owned()); + Some((pet, pet_body, pet_stats)) + } else { + warn!( + "Failed to deserialize pet_id: {} for character_id {}", + db_pet.database_id, char_id + ); + None + } + }) + .collect::>(); + Ok(( - convert_body_from_database(&body_data)?, + convert_body_from_database(&body_data.variant, &body_data.body_data)?, convert_stats_from_database(character_data.alias), convert_skill_set_from_database(&skill_data, &skill_group_data), convert_inventory_from_database_items( @@ -214,6 +262,7 @@ pub fn load_character_data( &loadout_items, )?, char_waypoint, + pets, )) } @@ -269,7 +318,7 @@ pub fn load_character_list(player_uuid_: &str, connection: &Connection) -> Chara })?; drop(stmt); - let char_body = convert_body_from_database(&db_body)?; + let char_body = convert_body_from_database(&db_body.variant, &db_body.body_data)?; let loadout_container_id = get_pseudo_container_id( connection, @@ -299,7 +348,7 @@ pub fn create_character( ) -> CharacterCreationResult { check_character_limit(uuid, transactionn)?; - let (body, _stats, skill_set, inventory, waypoint) = persisted_components; + let (body, _stats, skill_set, inventory, waypoint, _) = persisted_components; // Fetch new entity IDs for character, inventory and loadout let mut new_entity_ids = get_new_entity_ids(transactionn, |next_id| next_id + 3)?; @@ -362,10 +411,11 @@ pub fn create_character( VALUES (?1, ?2, ?3)", )?; + let (body_variant, body_json) = convert_body_to_database_json(&body)?; stmt.execute(&[ &character_id as &dyn ToSql, - &"humanoid".to_string(), - &convert_body_to_database_json(&body)?, + &body_variant.to_string(), + &body_json, ])?; drop(stmt); @@ -548,6 +598,14 @@ pub fn delete_character( ))); } + let pet_ids = get_pet_ids(char_id, transaction)? + .iter() + .map(|x| Value::from(*x)) + .collect::>(); + if !pet_ids.is_empty() { + delete_pets(transaction, char_id, Rc::new(pet_ids))?; + } + load_character_list(requesting_player_uuid, transaction) } @@ -678,17 +736,142 @@ fn get_pseudo_container_id( } } +/// Stores new pets in the database, and removes pets from the database that the +/// player no longer has. Currently there are no actual updates to pet data +/// since we don't store any updatable data about pets in the database. +fn update_pets( + char_id: CharacterId, + pets: Vec, + transaction: &mut Transaction, +) -> Result<(), PersistenceError> { + debug!("Updating {} pets for character {}", pets.len(), char_id); + + let db_pets = get_pet_ids(char_id, transaction)?; + if !db_pets.is_empty() { + let dead_pet_ids = Rc::new( + db_pets + .iter() + .filter(|pet_id| { + !pets.iter().any(|(pet, _, _)| { + pet.get_database_id() + .load() + .map_or(false, |x| x.get() == **pet_id as u64) + }) + }) + .map(|x| Value::from(*x)) + .collect::>(), + ); + + if !dead_pet_ids.is_empty() { + delete_pets(transaction, char_id, dead_pet_ids)?; + } + } + + for (pet, body, stats) in pets + .iter() + .filter(|(pet, _, _)| pet.get_database_id().load().is_none()) + { + let pet_entity_id = get_new_entity_ids(transaction, |next_id| next_id + 1)?.start; + + let (body_variant, body_json) = convert_body_to_database_json(body)?; + + #[rustfmt::skip] + let mut stmt = transaction.prepare_cached(" + INSERT + INTO body ( + body_id, + variant, + body_data) + VALUES (?1, ?2, ?3)" + )?; + + stmt.execute(&[ + &pet_entity_id as &dyn ToSql, + &body_variant.to_string(), + &body_json, + ])?; + + #[rustfmt::skip] + let mut stmt = transaction.prepare_cached(" + INSERT + INTO pet ( + pet_id, + character_id, + name) + VALUES (?1, ?2, ?3)", + )?; + + stmt.execute(&[&pet_entity_id as &dyn ToSql, &char_id, &stats.name])?; + drop(stmt); + + pet.get_database_id() + .store(NonZeroU64::new(pet_entity_id as u64)); + } + + Ok(()) +} + +fn get_pet_ids(char_id: i64, transaction: &mut Transaction) -> Result, PersistenceError> { + #[rustfmt::skip] + let mut stmt = transaction.prepare_cached(" + SELECT pet_id + FROM pet + WHERE character_id = ?1 + ")?; + + #[allow(clippy::needless_question_mark)] + let db_pets = stmt + .query_map(&[&char_id], |row| Ok(row.get(0)?))? + .map(|x| x.unwrap()) + .collect::>(); + drop(stmt); + Ok(db_pets) +} + +fn delete_pets( + transaction: &mut Transaction, + char_id: CharacterId, + pet_ids: Rc>, +) -> Result<(), PersistenceError> { + #[rustfmt::skip] + let mut stmt = transaction.prepare_cached(" + DELETE + FROM pet + WHERE pet_id IN rarray(?1)" + )?; + + let delete_count = stmt.execute(&[&pet_ids])?; + drop(stmt); + debug!("Deleted {} pets for character id {}", delete_count, char_id); + + #[rustfmt::skip] + let mut stmt = transaction.prepare_cached(" + DELETE + FROM body + WHERE body_id IN rarray(?1)" + )?; + + let delete_count = stmt.execute(&[&pet_ids])?; + debug!( + "Deleted {} pet bodies for character id {}", + delete_count, char_id + ); + + Ok(()) +} pub fn update( char_id: CharacterId, char_skill_set: comp::SkillSet, inventory: comp::Inventory, + pets: Vec, char_waypoint: Option, transaction: &mut Transaction, ) -> Result<(), PersistenceError> { + // Run pet persistence + update_pets(char_id, pets, transaction)?; + let pseudo_containers = get_pseudo_containers(transaction, char_id)?; - let mut upserts = Vec::new(); - // First, get all the entity IDs for any new items, and identify which // slots to upsert and which ones to delete. get_new_entity_ids(transaction, |mut next_id| { diff --git a/server/src/persistence/character/conversions.rs b/server/src/persistence/character/conversions.rs index 02a9d12a0e..b7618d15ca 100644 --- a/server/src/persistence/character/conversions.rs +++ b/server/src/persistence/character/conversions.rs @@ -1,11 +1,11 @@ use crate::persistence::{ character::EntityId, - models::{Body, Character, Item, Skill, SkillGroup}, + models::{Character, Item, Skill, SkillGroup}, }; use crate::persistence::{ error::PersistenceError, - json_models::{self, CharacterPosition, HumanoidBody}, + json_models::{self, CharacterPosition, GenericBody, HumanoidBody}, }; use common::{ character::CharacterId, @@ -23,7 +23,7 @@ use common::{ use core::{convert::TryFrom, num::NonZeroU64}; use hashbrown::HashMap; use lazy_static::lazy_static; -use std::{collections::VecDeque, sync::Arc}; +use std::{collections::VecDeque, str::FromStr, sync::Arc}; use tracing::trace; #[derive(Debug)] @@ -188,13 +188,33 @@ pub fn convert_items_to_database_items( upserts } -pub fn convert_body_to_database_json(body: &CompBody) -> Result { - let json_model = match body { - common::comp::Body::Humanoid(humanoid_body) => HumanoidBody::from(humanoid_body), - _ => unimplemented!("Only humanoid bodies are currently supported for persistence"), - }; - - serde_json::to_string(&json_model).map_err(PersistenceError::SerializationError) +pub fn convert_body_to_database_json( + comp_body: &CompBody, +) -> Result<(&str, String), PersistenceError> { + Ok(match comp_body { + common::comp::Body::Humanoid(body) => ( + "humanoid", + serde_json::to_string(&HumanoidBody::from(body))?, + ), + common::comp::Body::QuadrupedLow(body) => ( + "quadruped_low", + serde_json::to_string(&GenericBody::from(body))?, + ), + common::comp::Body::QuadrupedMedium(body) => ( + "quadruped_medium", + serde_json::to_string(&GenericBody::from(body))?, + ), + common::comp::Body::QuadrupedSmall(body) => ( + "quadruped_small", + serde_json::to_string(&GenericBody::from(body))?, + ), + _ => { + return Err(PersistenceError::ConversionError(format!( + "Unsupported body type for persistence: {:?}", + comp_body + ))); + }, + }) } pub fn convert_waypoint_to_database_json(waypoint: Option) -> Option { @@ -388,10 +408,39 @@ pub fn convert_loadout_from_database_items( Ok(loadout) } -pub fn convert_body_from_database(body: &Body) -> Result { - Ok(match body.variant.as_str() { +/// Generates the code to deserialize a specific body variant from JSON +macro_rules! deserialize_body { + ($body_data:expr, $body_variant:tt, $body_type:tt) => {{ + let json_model = serde_json::de::from_str::($body_data)?; + CompBody::$body_variant(common::comp::$body_type::Body { + species: common::comp::$body_type::Species::from_str(&json_model.species) + .map_err(|_| { + PersistenceError::ConversionError(format!( + "Missing species: {}", + json_model.species + )) + })? + .to_owned(), + body_type: common::comp::$body_type::BodyType::from_str(&json_model.body_type) + .map_err(|_| { + PersistenceError::ConversionError(format!( + "Missing body type: {}", + json_model.species + )) + })? + .to_owned(), + }) + }}; +} +pub fn convert_body_from_database( + variant: &str, + body_data: &str, +) -> Result { + Ok(match variant { + // The humanoid variant doesn't use the body_variant! macro as it is unique in having + // extra fields on its body struct "humanoid" => { - let json_model = serde_json::de::from_str::(&body.body_data)?; + let json_model = serde_json::de::from_str::(body_data)?; CompBody::Humanoid(common::comp::humanoid::Body { species: common::comp::humanoid::ALL_SPECIES .get(json_model.species as usize) @@ -420,10 +469,20 @@ pub fn convert_body_from_database(body: &Body) -> Result { + deserialize_body!(body_data, QuadrupedLow, quadruped_low) + }, + "quadruped_medium" => { + deserialize_body!(body_data, QuadrupedMedium, quadruped_medium) + }, + "quadruped_small" => { + deserialize_body!(body_data, QuadrupedSmall, quadruped_small) + }, _ => { - return Err(PersistenceError::ConversionError( - "Only humanoid bodies are supported for characters".to_string(), - )); + return Err(PersistenceError::ConversionError(format!( + "{} is not a supported body type for deserialization", + variant.to_string() + ))); }, }) } diff --git a/server/src/persistence/character_updater.rs b/server/src/persistence/character_updater.rs index 0311c722ac..da6cc159f0 100644 --- a/server/src/persistence/character_updater.rs +++ b/server/src/persistence/character_updater.rs @@ -18,7 +18,14 @@ use std::{ }; use tracing::{debug, error, info, trace, warn}; -pub type CharacterUpdateData = (comp::SkillSet, comp::Inventory, Option); +pub type CharacterUpdateData = ( + comp::SkillSet, + comp::Inventory, + Vec, + Option, +); + +pub type PetPersistenceData = (comp::Pet, comp::Body, comp::Stats); #[allow(clippy::large_enum_variant)] pub enum CharacterUpdaterEvent { @@ -258,15 +265,21 @@ impl CharacterUpdater { CharacterId, &'a comp::SkillSet, &'a comp::Inventory, + Vec, Option<&'a comp::Waypoint>, ), >, ) { let updates = updates - .map(|(character_id, skill_set, inventory, waypoint)| { + .map(|(character_id, skill_set, inventory, pets, waypoint)| { ( character_id, - (skill_set.clone(), inventory.clone(), waypoint.cloned()), + ( + skill_set.clone(), + inventory.clone(), + pets, + waypoint.cloned(), + ), ) }) .chain(self.pending_logout_updates.drain()) @@ -308,8 +321,15 @@ fn execute_batch_update( trace!("Transaction started for character batch update"); updates .into_iter() - .try_for_each(|(character_id, (stats, inventory, waypoint))| { - super::character::update(character_id, stats, inventory, waypoint, &mut transaction) + .try_for_each(|(character_id, (stats, inventory, pets, waypoint))| { + super::character::update( + character_id, + stats, + inventory, + pets, + waypoint, + &mut transaction, + ) })?; transaction.commit()?; diff --git a/server/src/persistence/json_models.rs b/server/src/persistence/json_models.rs index 0dba8faf06..fe59dd22f0 100644 --- a/server/src/persistence/json_models.rs +++ b/server/src/persistence/json_models.rs @@ -1,5 +1,6 @@ use common::comp; use serde::{Deserialize, Serialize}; +use std::string::ToString; use vek::Vec3; #[derive(Serialize, Deserialize)] @@ -31,6 +32,32 @@ impl From<&comp::humanoid::Body> for HumanoidBody { } } +/// A serializable model used to represent a generic Body. Since all variants +/// of Body except Humanoid (currently) have the same struct layout, a single +/// struct is used for persistence conversions. +#[derive(Serialize, Deserialize)] +pub struct GenericBody { + pub species: String, + pub body_type: String, +} + +macro_rules! generic_body_from_impl { + ($body_type:ty) => { + impl From<&$body_type> for GenericBody { + fn from(body: &$body_type) -> Self { + GenericBody { + species: body.species.to_string(), + body_type: body.body_type.to_string(), + } + } + } + }; +} + +generic_body_from_impl!(comp::quadruped_low::Body); +generic_body_from_impl!(comp::quadruped_medium::Body); +generic_body_from_impl!(comp::quadruped_small::Body); + #[derive(Serialize, Deserialize)] pub struct CharacterPosition { pub waypoint: Vec3, diff --git a/server/src/persistence/mod.rs b/server/src/persistence/mod.rs index 3662b3ddb8..298d8224a7 100644 --- a/server/src/persistence/mod.rs +++ b/server/src/persistence/mod.rs @@ -8,6 +8,7 @@ pub mod error; mod json_models; mod models; +use crate::persistence::character_updater::PetPersistenceData; use common::comp; use refinery::Report; use rusqlite::{Connection, OpenFlags}; @@ -27,6 +28,7 @@ pub type PersistedComponents = ( comp::SkillSet, comp::Inventory, Option, + Vec, ); // See: https://docs.rs/refinery/0.5.0/refinery/macro.embed_migrations.html diff --git a/server/src/persistence/models.rs b/server/src/persistence/models.rs index a061731483..a2ce2d18dc 100644 --- a/server/src/persistence/models.rs +++ b/server/src/persistence/models.rs @@ -33,3 +33,10 @@ pub struct SkillGroup { pub available_sp: i32, pub earned_sp: i32, } + +pub struct Pet { + pub database_id: i64, + pub name: String, + pub body_variant: String, + pub body_data: String, +} diff --git a/server/src/pet.rs b/server/src/pet.rs new file mode 100644 index 0000000000..1077b6fe9f --- /dev/null +++ b/server/src/pet.rs @@ -0,0 +1,77 @@ +use crate::client::Client; +use common::{ + comp::{anchor::Anchor, group::GroupManager, Agent, Alignment, Pet}, + uid::Uid, +}; +use common_net::msg::ServerGeneral; +use specs::{Entity, WorldExt}; +use tracing::warn; + +/// Restores a pet retrieved from the database on login, assigning it to its +/// owner +pub fn restore_pet(ecs: &specs::World, pet_entity: Entity, owner: Entity, pet: Pet) { + tame_pet_internal(ecs, pet_entity, owner, Some(pet)); +} + +/// Tames a pet, adding to the owner's group and setting its alignment +pub fn tame_pet(ecs: &specs::World, pet_entity: Entity, owner: Entity) { + tame_pet_internal(ecs, pet_entity, owner, None); +} + +fn tame_pet_internal(ecs: &specs::World, pet_entity: Entity, owner: Entity, pet: Option) { + let uids = ecs.read_storage::(); + let owner_uid = match uids.get(owner) { + Some(uid) => *uid, + None => return, + }; + + if let Some(Alignment::Owned(existing_owner_uid)) = + ecs.read_storage::().get(pet_entity) + { + if *existing_owner_uid != owner_uid { + warn!("Disallowing taming of pet already owned by another entity"); + return; + } + } + + let _ = ecs + .write_storage() + .insert(pet_entity, common::comp::Alignment::Owned(owner_uid)); + + // Anchor the pet to the player to prevent it de-spawning + // when its chunk is unloaded if its owner is still logged + // in + let _ = ecs + .write_storage() + .insert(pet_entity, Anchor::Entity(owner)); + + let _ = ecs + .write_storage() + .insert(pet_entity, pet.unwrap_or_default()); + + // TODO: Review whether we should be doing this or not, should the Agent always + // be overwritten when taming a pet? + let _ = ecs.write_storage().insert(pet_entity, Agent::default()); + + // Add to group system + let clients = ecs.read_storage::(); + let mut group_manager = ecs.write_resource::(); + group_manager.new_pet( + pet_entity, + owner, + &mut ecs.write_storage(), + &ecs.entities(), + &ecs.read_storage(), + &uids, + &mut |entity, group_change| { + clients + .get(entity) + .and_then(|c| { + group_change + .try_map(|e| uids.get(e).copied()) + .map(|g| (g, c)) + }) + .map(|(g, c)| c.send_fallible(ServerGeneral::GroupUpdate(g))); + }, + ); +} diff --git a/server/src/rtsim/tick.rs b/server/src/rtsim/tick.rs index 96d96f5461..19139db36b 100644 --- a/server/src/rtsim/tick.rs +++ b/server/src/rtsim/tick.rs @@ -153,7 +153,7 @@ impl<'a> System<'a> for Sys { _ => comp::Scale(1.0), }, drop_item: None, - home_chunk: None, + anchor: None, rtsim_entity, projectile: None, }, diff --git a/server/src/state_ext.rs b/server/src/state_ext.rs index 0e6044d517..90bb228af1 100644 --- a/server/src/state_ext.rs +++ b/server/src/state_ext.rs @@ -1,6 +1,7 @@ use crate::{ client::Client, persistence::PersistedComponents, + pet::restore_pet, presence::{Presence, RepositionOnChunkLoad}, settings::Settings, sys::sentinel::DeletedEntities, @@ -12,7 +13,7 @@ use common::{ comp::{ self, skills::{GeneralSkill, Skill}, - Group, Inventory, + Group, Inventory, Poise, }, effect::Effect, resources::TimeOfDay, @@ -30,7 +31,7 @@ use specs::{ Join, WorldExt, }; use std::time::Duration; -use tracing::warn; +use tracing::{trace, warn}; use vek::*; pub trait StateExt { @@ -485,7 +486,7 @@ impl StateExt for State { } fn update_character_data(&mut self, entity: EcsEntity, components: PersistedComponents) { - let (body, stats, skill_set, inventory, waypoint) = components; + let (body, stats, skill_set, inventory, waypoint, pets) = components; if let Some(player_uid) = self.read_component_copied::(entity) { // Notify clients of a player list update @@ -535,6 +536,38 @@ impl StateExt for State { self.write_component_ignore_entity_dead(entity, comp::Vel(Vec3::zero())); self.write_component_ignore_entity_dead(entity, comp::ForceUpdate); } + + let player_pos = self.ecs().read_storage::().get(entity).copied(); + if let Some(player_pos) = player_pos { + trace!( + "Loading {} pets for player at pos {:?}", + pets.len(), + player_pos + ); + // This is the same as wild creatures naturally spawned in the world + const DEFAULT_PET_HEALTH_LEVEL: u16 = 0; + + for (pet, body, stats) in pets { + let pet_entity = self + .create_npc( + player_pos, + stats, + comp::SkillSet::default(), + Some(comp::Health::new(body, DEFAULT_PET_HEALTH_LEVEL)), + Poise::new(body), + Inventory::new_empty(), + body, + ) + .with(comp::Scale(1.0)) + .with(comp::Vel(Vec3::new(0.0, 0.0, 0.0))) + .with(comp::MountState::Unmounted) + .build(); + + restore_pet(self.ecs(), pet_entity, entity, pet); + } + } else { + warn!("Player has no pos, cannot load {} pets", pets.len()); + } } } diff --git a/server/src/sys/mod.rs b/server/src/sys/mod.rs index bdf98b6a22..64ea2d60b8 100644 --- a/server/src/sys/mod.rs +++ b/server/src/sys/mod.rs @@ -5,6 +5,7 @@ pub mod metrics; pub mod msg; pub mod object; pub mod persistence; +pub mod pets; pub mod sentinel; pub mod subscription; pub mod terrain; diff --git a/server/src/sys/msg/mod.rs b/server/src/sys/msg/mod.rs index a8e7ffabba..0edd89ecef 100644 --- a/server/src/sys/msg/mod.rs +++ b/server/src/sys/msg/mod.rs @@ -5,7 +5,7 @@ pub mod ping; pub mod register; pub mod terrain; -use crate::client::Client; +use crate::{client::Client, sys::pets}; use common_ecs::{dispatch, System}; use serde::de::DeserializeOwned; use specs::DispatcherBuilder; @@ -19,6 +19,7 @@ pub fn add_server_systems(dispatch_builder: &mut DispatcherBuilder) { dispatch::(dispatch_builder, &[&general::Sys::sys_name()]); dispatch::(dispatch_builder, &[]); dispatch::(dispatch_builder, &[]); + dispatch::(dispatch_builder, &[]); } /// handles all send msg and calls a handle fn diff --git a/server/src/sys/persistence.rs b/server/src/sys/persistence.rs index bfac106095..addbc00cf3 100644 --- a/server/src/sys/persistence.rs +++ b/server/src/sys/persistence.rs @@ -1,5 +1,11 @@ use crate::{persistence::character_updater, presence::Presence, sys::SysScheduler}; -use common::comp::{Inventory, SkillSet, Waypoint}; +use common::{ + comp::{ + pet::{is_tameable, Pet}, + Alignment, Body, Inventory, SkillSet, Stats, Waypoint, + }, + uid::Uid, +}; use common_ecs::{Job, Origin, Phase, System}; use common_net::msg::PresenceKind; use specs::{Join, ReadStorage, Write, WriteExpect}; @@ -10,10 +16,15 @@ pub struct Sys; impl<'a> System<'a> for Sys { #[allow(clippy::type_complexity)] type SystemData = ( + ReadStorage<'a, Alignment>, + ReadStorage<'a, Body>, ReadStorage<'a, Presence>, ReadStorage<'a, SkillSet>, ReadStorage<'a, Inventory>, + ReadStorage<'a, Uid>, ReadStorage<'a, Waypoint>, + ReadStorage<'a, Pet>, + ReadStorage<'a, Stats>, WriteExpect<'a, character_updater::CharacterUpdater>, Write<'a, SysScheduler>, ); @@ -25,10 +36,15 @@ impl<'a> System<'a> for Sys { fn run( _job: &mut Job, ( + alignments, + bodies, presences, player_skill_set, player_inventories, - player_waypoint, + uids, + player_waypoints, + pets, + stats, mut updater, mut scheduler, ): Self::SystemData, @@ -39,13 +55,30 @@ impl<'a> System<'a> for Sys { &presences, &player_skill_set, &player_inventories, - player_waypoint.maybe(), + &uids, + player_waypoints.maybe(), ) .join() .filter_map( - |(presence, skill_set, inventory, waypoint)| match presence.kind { + |(presence, skill_set, inventory, player_uid, waypoint)| match presence.kind + { PresenceKind::Character(id) => { - Some((id, skill_set, inventory, waypoint)) + let pets = (&alignments, &bodies, &stats, &pets) + .join() + .filter_map(|(alignment, body, stats, pet)| match alignment { + // Don't try to persist non-tameable pets (likely spawned + // using /spawn) since there isn't any code to handle + // persisting them + Alignment::Owned(ref pet_owner) + if pet_owner == player_uid && is_tameable(body) => + { + Some(((*pet).clone(), *body, stats.clone())) + }, + _ => None, + }) + .collect(); + + Some((id, skill_set, inventory, pets, waypoint)) }, PresenceKind::Spectator => None, }, diff --git a/server/src/sys/pets.rs b/server/src/sys/pets.rs new file mode 100644 index 0000000000..d958bcfab5 --- /dev/null +++ b/server/src/sys/pets.rs @@ -0,0 +1,75 @@ +use common::{ + comp::{Alignment, Pet, PhysicsState, Pos}, + terrain::TerrainGrid, + uid::UidAllocator, +}; +use common_ecs::{Job, Origin, Phase, System}; +use specs::{ + saveload::MarkerAllocator, Entities, Entity, Join, Read, ReadExpect, ReadStorage, WriteStorage, +}; + +/// This system is responsible for handling pets +#[derive(Default)] +pub struct Sys; +impl<'a> System<'a> for Sys { + #[allow(clippy::type_complexity)] + type SystemData = ( + Entities<'a>, + ReadExpect<'a, TerrainGrid>, + WriteStorage<'a, Pos>, + ReadStorage<'a, Alignment>, + ReadStorage<'a, Pet>, + ReadStorage<'a, PhysicsState>, + Read<'a, UidAllocator>, + ); + + const NAME: &'static str = "pets"; + const ORIGIN: Origin = Origin::Server; + const PHASE: Phase = Phase::Create; + + fn run( + _job: &mut Job, + (entities, terrain, mut positions, alignments, pets, physics, uid_allocator): Self::SystemData, + ) { + const LOST_PET_DISTANCE_THRESHOLD: f32 = 200.0; + + // Find pets that are too far away from their owner + let lost_pets: Vec<(Entity, Pos)> = (&entities, &positions, &alignments, &pets) + .join() + .filter_map(|(entity, pos, alignment, _)| match alignment { + Alignment::Owned(owner_uid) => Some((entity, pos, owner_uid)), + _ => None, + }) + .filter_map(|(pet_entity, pet_pos, owner_uid)| { + uid_allocator + .retrieve_entity_internal(owner_uid.0) + .and_then(|owner_entity| { + match (positions.get(owner_entity), physics.get(owner_entity)) { + (Some(position), Some(physics)) => { + Some((pet_entity, position, physics, pet_pos)) + }, + _ => None, + } + }) + }) + .filter(|(_, owner_pos, owner_physics, pet_pos)| { + // Don't teleport pets to the player if they're in the air, nobody wants + // pets to go splat :( + owner_physics.on_ground.is_some() + && owner_pos.0.distance_squared(pet_pos.0) > LOST_PET_DISTANCE_THRESHOLD.powi(2) + }) + .map(|(entity, owner_pos, _, _)| (entity, *owner_pos)) + .collect(); + + for (pet_entity, owner_pos) in lost_pets.iter() { + if let Some(mut pet_pos) = positions.get_mut(*pet_entity) { + // Move the pets to their owner's position + // TODO: Create a teleportation event to handle this instead of + // processing the entity position move here + pet_pos.0 = terrain + .find_space(owner_pos.0.map(|e| e.floor() as i32)) + .map(|e| e as f32); + } + } + } +} diff --git a/server/src/sys/terrain.rs b/server/src/sys/terrain.rs index ce1b16c31b..114a771019 100644 --- a/server/src/sys/terrain.rs +++ b/server/src/sys/terrain.rs @@ -312,7 +312,7 @@ impl<'a> System<'a> for Sys { body, alignment, scale: comp::Scale(scale), - home_chunk: Some(comp::HomeChunk(key)), + anchor: Some(comp::Anchor::Chunk(key)), drop_item: entity.loot_drop, rtsim_entity: None, projectile: None,