From 50858245d94a8be49e21903a6e4f5510a2940950 Mon Sep 17 00:00:00 2001 From: Imbris Date: Thu, 6 Aug 2020 21:59:28 -0400 Subject: [PATCH] Add timeout's to group invites, and configurable limit to group size Fix a few group bugs, enable invite timeout and group limits in ui --- .cargo/config | 4 +- Cargo.lock | 32 +---- client/src/lib.rs | 50 ++++++- common/src/comp/group.rs | 66 ++++++--- common/src/msg/mod.rs | 2 +- common/src/msg/server.rs | 24 +++- common/src/state.rs | 2 + common/src/sys/agent.rs | 26 +++- server/src/client.rs | 1 - server/src/events/group_manip.rs | 228 ++++++++++++++++++++++--------- server/src/events/player.rs | 2 +- server/src/lib.rs | 12 +- server/src/settings.rs | 2 + server/src/sys/invite_timeout.rs | 71 ++++++++++ server/src/sys/mod.rs | 4 + voxygen/src/hud/group.rs | 11 +- voxygen/src/hud/social.rs | 102 ++++++++------ 17 files changed, 467 insertions(+), 172 deletions(-) create mode 100644 server/src/sys/invite_timeout.rs diff --git a/.cargo/config b/.cargo/config index 6a9b75f52a..e4f3071f5c 100644 --- a/.cargo/config +++ b/.cargo/config @@ -4,4 +4,6 @@ rustflags = [ ] [alias] -generate = "run --package tools --" \ No newline at end of file +generate = "run --package tools --" +test-server = "run --bin veloren-server-cli --no-default-features" + diff --git a/Cargo.lock b/Cargo.lock index bb5d2f2430..709ab9ea4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,16 +219,6 @@ dependencies = [ "winapi 0.3.8", ] -[[package]] -name = "auth-common" -version = "0.1.0" -source = "git+https://gitlab.com/veloren/auth.git?rev=223a4097f7ebc8d451936dccb5e6517194bbf086#223a4097f7ebc8d451936dccb5e6517194bbf086" -dependencies = [ - "rand 0.7.3", - "serde", - "uuid", -] - [[package]] name = "auth-common" version = "0.1.0" @@ -239,26 +229,12 @@ dependencies = [ "uuid", ] -[[package]] -name = "authc" -version = "1.0.0" -source = "git+https://gitlab.com/veloren/auth.git?rev=223a4097f7ebc8d451936dccb5e6517194bbf086#223a4097f7ebc8d451936dccb5e6517194bbf086" -dependencies = [ - "auth-common 0.1.0 (git+https://gitlab.com/veloren/auth.git?rev=223a4097f7ebc8d451936dccb5e6517194bbf086)", - "fxhash", - "hex", - "rust-argon2 0.8.2", - "serde_json", - "ureq", - "uuid", -] - [[package]] name = "authc" version = "1.0.0" source = "git+https://gitlab.com/veloren/auth.git?rev=b943c85e4a38f5ec60cd18c34c73097640162bfe#b943c85e4a38f5ec60cd18c34c73097640162bfe" dependencies = [ - "auth-common 0.1.0 (git+https://gitlab.com/veloren/auth.git?rev=b943c85e4a38f5ec60cd18c34c73097640162bfe)", + "auth-common", "fxhash", "hex", "rust-argon2 0.8.2", @@ -4625,7 +4601,7 @@ dependencies = [ name = "veloren-client" version = "0.6.0" dependencies = [ - "authc 1.0.0 (git+https://gitlab.com/veloren/auth.git?rev=b943c85e4a38f5ec60cd18c34c73097640162bfe)", + "authc", "byteorder 1.3.4", "futures-executor", "futures-timer", @@ -4646,7 +4622,7 @@ name = "veloren-common" version = "0.6.0" dependencies = [ "arraygen", - "authc 1.0.0 (git+https://gitlab.com/veloren/auth.git?rev=223a4097f7ebc8d451936dccb5e6517194bbf086)", + "authc", "criterion", "crossbeam", "dot_vox", @@ -4674,7 +4650,7 @@ dependencies = [ name = "veloren-server" version = "0.6.0" dependencies = [ - "authc 1.0.0 (git+https://gitlab.com/veloren/auth.git?rev=b943c85e4a38f5ec60cd18c34c73097640162bfe)", + "authc", "chrono", "crossbeam", "diesel", diff --git a/client/src/lib.rs b/client/src/lib.rs index ee8d99224f..aec0cb919c 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -23,7 +23,7 @@ use common::{ msg::{ validate_chat_msg, ChatMsgValidationError, ClientMsg, ClientState, Notification, PlayerInfo, PlayerListUpdate, RegisterError, RequestStateError, ServerInfo, ServerMsg, - MAX_BYTES_CHAT_MSG, + MAX_BYTES_CHAT_MSG, InviteAnswer, }, recipe::RecipeBook, state::State, @@ -79,9 +79,14 @@ pub struct Client { recipe_book: RecipeBook, available_recipes: HashSet, - group_invite: Option, + max_group_size: u32, + // Client has received an invite (inviter uid, time out instant) + group_invite: Option<(Uid, std::time::Instant, std::time::Duration)>, group_leader: Option, + // Note: potentially representable as a client only component group_members: HashMap, + // Pending invites that this client has sent out + pending_invites: HashSet, _network: Network, participant: Option, @@ -130,13 +135,14 @@ impl Client { let mut stream = block_on(participant.open(10, PROMISES_ORDERED | PROMISES_CONSISTENCY))?; // Wait for initial sync - let (state, entity, server_info, world_map, recipe_book) = block_on(async { + let (state, entity, server_info, world_map, recipe_book, max_group_size) = block_on(async { loop { match stream.recv().await? { ServerMsg::InitialSync { entity_package, server_info, time_of_day, + max_group_size, world_map: (map_size, world_map), recipe_book, } => { @@ -188,6 +194,7 @@ impl Client { server_info, (world_map, map_size), recipe_book, + max_group_size, )); }, ServerMsg::TooManyPlayers => break Err(Error::TooManyPlayers), @@ -212,14 +219,16 @@ impl Client { server_info, world_map, player_list: HashMap::new(), - group_members: HashMap::new(), character_list: CharacterList::default(), active_character_id: None, recipe_book, available_recipes: HashSet::default(), + max_group_size, group_invite: None, group_leader: None, + group_members: HashMap::new(), + pending_invites: HashSet::new(), _network: network, participant: Some(participant), @@ -432,7 +441,9 @@ impl Client { .unwrap(); } - pub fn group_invite(&self) -> Option { self.group_invite } + pub fn max_group_size(&self) -> u32 { self.max_group_size } + + pub fn group_invite(&self) -> Option<(Uid, std::time::Instant, std::time::Duration)> { self.group_invite } pub fn group_info(&self) -> Option<(String, Uid)> { self.group_leader.map(|l| ("Group".into(), l)) // TODO @@ -440,6 +451,8 @@ impl Client { pub fn group_members(&self) -> &HashMap { &self.group_members } + pub fn pending_invites(&self) -> &HashSet { &self.pending_invites } + pub fn send_group_invite(&mut self, invitee: Uid) { self.singleton_stream .send(ClientMsg::ControlEvent(ControlEvent::GroupManip( @@ -758,6 +771,10 @@ impl Client { frontend_events.append(&mut self.handle_new_messages()?); // 3) Update client local data + // Check if the group invite has timed out and remove if so + if self.group_invite.map_or(false, |(_, timeout, dur)| timeout.elapsed() > dur) { + self.group_invite = None; + } // 4) Tick the client's LocalState self.state.tick(dt, add_foreign_systems, true); @@ -1046,9 +1063,28 @@ impl Client { }, } }, - ServerMsg::GroupInvite(uid) => { - self.group_invite = Some(uid); + ServerMsg::GroupInvite { inviter, timeout } => { + self.group_invite = Some((inviter, std::time::Instant::now(), timeout)); }, + ServerMsg::InvitePending(uid) => { + if !self.pending_invites.insert(uid) { + warn!("Received message about pending invite that was already pending"); + } + } + ServerMsg::InviteComplete { target, answer } => { + if !self.pending_invites.remove(&target) { + warn!("Received completed invite message for invite that was not in the list of pending invites") + } + // TODO: expose this as a new event variant instead of going + // through the chat + let msg = match answer { + // TODO: say who accepted/declined/timed out the invite + InviteAnswer::Accepted => "Invite accepted", + InviteAnswer::Declined => "Invite declined", + InviteAnswer::TimedOut => "Invite timed out", + }; + frontend_events.push(Event::Chat(comp::ChatType::Meta.chat_msg(msg))); + } ServerMsg::Ping => { self.singleton_stream.send(ClientMsg::Pong)?; }, diff --git a/common/src/comp/group.rs b/common/src/comp/group.rs index 84356fb599..18de30ed8b 100644 --- a/common/src/comp/group.rs +++ b/common/src/comp/group.rs @@ -9,9 +9,11 @@ use tracing::{error, warn}; // Primitive group system // Shortcomings include: // - no support for more complex group structures -// - lack of complex enemy npc integration +// - lack of npc group integration // - relies on careful management of groups to maintain a valid state // - the possesion rod could probably wreck this +// - clients don't know which pets are theirs (could be easy to solve by +// putting owner uid in Role::Pet) #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Group(u32); @@ -26,11 +28,26 @@ impl Component for Group { type Storage = FlaggedStorage>; } +pub struct Invite(pub specs::Entity); +impl Component for Invite { + type Storage = IdvStorage; +} + +// Pending invites that an entity currently has sent out +// (invited entity, instant when invite times out) +pub struct PendingInvites(pub Vec<(specs::Entity, std::time::Instant)>); +impl Component for PendingInvites { + type Storage = IdvStorage; +} + #[derive(Clone, Debug)] pub struct GroupInfo { // TODO: what about enemy groups, either the leader will constantly change because they have to // be loaded or we create a dummy entity or this needs to be optional pub leader: specs::Entity, + // Number of group members (excluding pets) + pub num_members: u32, + // Name of the group pub name: String, } @@ -134,9 +151,14 @@ impl GroupManager { self.groups.get(group.0 as usize) } - fn create_group(&mut self, leader: specs::Entity) -> Group { + fn group_info_mut(&mut self, group: Group) -> Option<&mut GroupInfo> { + self.groups.get_mut(group.0 as usize) + } + + fn create_group(&mut self, leader: specs::Entity, num_members: u32) -> Group { Group(self.groups.insert(GroupInfo { leader, + num_members, name: "Group".into(), }) as u32) } @@ -204,15 +226,20 @@ impl GroupManager { None => None, }; - let group = group.unwrap_or_else(|| { - let new_group = self.create_group(leader); + let group = if let Some(group) = group { + // Increment group size + // Note: unwrap won't fail since we just retrieved the group successfully above + self.group_info_mut(group).unwrap().num_members += 1; + group + } else { + let new_group = self.create_group(leader, 2); // Unwrap should not fail since we just found these entities and they should // still exist Note: if there is an issue replace with a warn groups.insert(leader, new_group).unwrap(); // Inform notifier(leader, ChangeNotification::NewLeader(leader)); new_group - }); + }; let new_pets = pets(new_member, new_member_uid, alignments, entities); @@ -256,7 +283,7 @@ impl GroupManager { let group = match groups.get(owner).copied() { Some(group) => group, None => { - let new_group = self.create_group(owner); + let new_group = self.create_group(owner, 1); groups.insert(owner, new_group).unwrap(); // Inform notifier(owner, ChangeNotification::NewLeader(owner)); @@ -348,7 +375,7 @@ impl GroupManager { (entities, uids, &*groups, alignments.maybe()) .join() - .filter(|(e, _, g, _)| **g == group && (!to_be_deleted || *e == member)) + .filter(|(e, _, g, _)| **g == group && !(to_be_deleted && *e == member)) .fold( HashMap::, Vec)>::new(), |mut acc, (e, uid, _, alignment)| { @@ -356,9 +383,11 @@ impl GroupManager { Alignment::Owned(owner) if uid != owner => Some(owner), _ => None, }) { + // A pet // Assumes owner will be in the group acc.entry(*owner).or_default().1.push(e); } else { + // Not a pet acc.entry(*uid).or_default().0 = Some(e); } @@ -375,7 +404,7 @@ impl GroupManager { members.push((owner, Role::Member)); // New group - let new_group = self.create_group(owner); + let new_group = self.create_group(owner, 1); for (member, _) in &members { groups.insert(*member, new_group).unwrap(); } @@ -401,7 +430,7 @@ impl GroupManager { let leaving_member_uid = if let Some(uid) = uids.get(member) { *uid } else { - error!("Failed to retrieve uid for the new group member"); + error!("Failed to retrieve uid for the leaving member"); return; }; @@ -409,7 +438,7 @@ impl GroupManager { // If pets and not about to be deleted form new group if !leaving_pets.is_empty() && !to_be_deleted { - let new_group = self.create_group(member); + let new_group = self.create_group(member, 1); notifier(member, ChangeNotification::NewGroup { leader: member, @@ -432,12 +461,11 @@ impl GroupManager { }); } - if let Some(info) = self.group_info(group) { + if let Some(info) = self.group_info_mut(group) { + info.num_members -= 1; // Inform remaining members - let mut num_members = 0; - members(group, &*groups, entities, alignments, uids).for_each(|(e, role)| { - num_members += 1; - match role { + members(group, &*groups, entities, alignments, uids).for_each( + |(e, role)| match role { Role::Member => { notifier(e, ChangeNotification::Removed(member)); leaving_pets.iter().for_each(|p| { @@ -445,16 +473,16 @@ impl GroupManager { }) }, Role::Pet => {}, - } - }); + }, + ); // If leader is the last one left then disband the group // Assumes last member is the leader - if num_members == 1 { + if info.num_members == 1 { let leader = info.leader; self.remove_group(group); groups.remove(leader); notifier(leader, ChangeNotification::NoGroup); - } else if num_members == 0 { + } else if info.num_members == 0 { error!("Somehow group has no members") } } diff --git a/common/src/msg/mod.rs b/common/src/msg/mod.rs index f7d8c3d118..4f2f1585b1 100644 --- a/common/src/msg/mod.rs +++ b/common/src/msg/mod.rs @@ -7,7 +7,7 @@ pub use self::{ client::ClientMsg, ecs_packet::EcsCompPacket, server::{ - CharacterInfo, Notification, PlayerInfo, PlayerListUpdate, RegisterError, + CharacterInfo, InviteAnswer, Notification, PlayerInfo, PlayerListUpdate, RegisterError, RequestStateError, ServerInfo, ServerMsg, }, }; diff --git a/common/src/msg/server.rs b/common/src/msg/server.rs index 4c3fda2aab..156c18fd61 100644 --- a/common/src/msg/server.rs +++ b/common/src/msg/server.rs @@ -47,6 +47,13 @@ pub struct CharacterInfo { pub level: u32, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum InviteAnswer { + Accepted, + Declined, + TimedOut, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Notification { WaypointSaved, @@ -59,6 +66,7 @@ pub enum ServerMsg { entity_package: sync::EntityPackage, server_info: ServerInfo, time_of_day: state::TimeOfDay, + max_group_size: u32, world_map: (Vec2, Vec), recipe_book: RecipeBook, }, @@ -70,7 +78,21 @@ pub enum ServerMsg { CharacterActionError(String), PlayerListUpdate(PlayerListUpdate), GroupUpdate(comp::group::ChangeNotification), - GroupInvite(sync::Uid), + // Indicate to the client that they are invited to join a group + GroupInvite { + inviter: sync::Uid, + timeout: std::time::Duration, + }, + // Indicate to the client that their sent invite was not invalid and is currently pending + InvitePending(sync::Uid), + // Note: this could potentially include all the failure cases such as inviting yourself in + // which case the `InvitePending` message could be removed and the client could consider their + // invite pending until they receive this message + // Indicate to the client the result of their invite + InviteComplete { + target: sync::Uid, + answer: InviteAnswer, + }, StateAnswer(Result), /// Trigger cleanup for when the client goes back to the `Registered` state /// from an ingame state diff --git a/common/src/state.rs b/common/src/state.rs index a76698c944..3d003b7a4f 100644 --- a/common/src/state.rs +++ b/common/src/state.rs @@ -158,6 +158,8 @@ impl State { ecs.register::(); ecs.register::(); ecs.register::(); + ecs.register::(); + ecs.register::(); // Register synced resources used by the ECS. ecs.insert(TimeOfDay(0.0)); diff --git a/common/src/sys/agent.rs b/common/src/sys/agent.rs index 51e31a7f14..e2cb88b750 100644 --- a/common/src/sys/agent.rs +++ b/common/src/sys/agent.rs @@ -3,9 +3,11 @@ use crate::{ self, agent::Activity, group, + group::Invite, item::{tool::ToolKind, ItemKind}, - Agent, Alignment, Body, CharacterState, ControlAction, Controller, Loadout, MountState, - Ori, PhysicsState, Pos, Scale, Stats, UnresolvedChatMsg, Vel, + Agent, Alignment, Body, CharacterState, ControlAction, ControlEvent, Controller, + GroupManip, Loadout, MountState, Ori, PhysicsState, Pos, Scale, Stats, UnresolvedChatMsg, + Vel, }, event::{EventBus, ServerEvent}, path::{Chaser, TraversalConfig}, @@ -49,6 +51,7 @@ impl<'a> System<'a> for Sys { WriteStorage<'a, Agent>, WriteStorage<'a, Controller>, ReadStorage<'a, MountState>, + ReadStorage<'a, Invite>, ); #[allow(clippy::or_fun_call)] // TODO: Pending review in #587 @@ -77,6 +80,7 @@ impl<'a> System<'a> for Sys { mut agents, mut controllers, mount_states, + invites, ): Self::SystemData, ) { for ( @@ -494,5 +498,23 @@ impl<'a> System<'a> for Sys { debug_assert!(inputs.move_dir.map(|e| !e.is_nan()).reduce_and()); debug_assert!(inputs.look_dir.map(|e| !e.is_nan()).reduce_and()); } + + // Proccess group invites + for (_invite, alignment, agent, controller) in + (&invites, &alignments, &mut agents, &mut controllers).join() + { + let accept = matches!(alignment, Alignment::Npc); + if accept { + // Clear agent comp + *agent = Agent::default(); + controller + .events + .push(ControlEvent::GroupManip(GroupManip::Accept)); + } else { + controller + .events + .push(ControlEvent::GroupManip(GroupManip::Decline)); + } + } } } diff --git a/server/src/client.rs b/server/src/client.rs index 5c14688ae2..95e6d96b91 100644 --- a/server/src/client.rs +++ b/server/src/client.rs @@ -18,7 +18,6 @@ pub struct Client { pub network_error: AtomicBool, pub last_ping: f64, pub login_msg_sent: bool, - pub invited_to_group: Option, } impl Component for Client { diff --git a/server/src/events/group_manip.rs b/server/src/events/group_manip.rs index 1479823d43..d1a030ce1c 100644 --- a/server/src/events/group_manip.rs +++ b/server/src/events/group_manip.rs @@ -2,18 +2,25 @@ use crate::{client::Client, Server}; use common::{ comp::{ self, - group::{self, GroupManager}, + group::{self, Group, GroupManager, Invite, PendingInvites}, ChatType, GroupManip, }, - msg::ServerMsg, + msg::{InviteAnswer, ServerMsg}, sync, sync::WorldSyncExt, }; use specs::world::WorldExt; -use tracing::warn; +use std::time::{Duration, Instant}; +use tracing::{error, warn}; + +/// Time before invite times out +const INVITE_TIMEOUT_DUR: Duration = Duration::from_secs(31); +/// Reduced duration shown to the client to help alleviate latency issues +const PRESENTED_INVITE_TIMEOUT_DUR: Duration = Duration::from_secs(30); // TODO: turn chat messages into enums pub fn handle_group(server: &mut Server, entity: specs::Entity, manip: GroupManip) { + let max_group_size = server.settings().max_player_group_size; let state = server.state_mut(); match manip { @@ -44,38 +51,62 @@ pub fn handle_group(server: &mut Server, entity: specs::Entity, manip: GroupMani return; } - let alignments = state.ecs().read_storage::(); - let agents = state.ecs().read_storage::(); - let mut already_has_invite = false; - let mut add_to_group = false; - // If client comp - if let (Some(client), Some(inviter_uid)) = (clients.get_mut(invitee), uids.get(entity)) - { - if client.invited_to_group.is_some() { - already_has_invite = true; - } else { - client.notify(ServerMsg::GroupInvite(*inviter_uid)); - client.invited_to_group = Some(entity); + // Disallow inviting entity that is already in your group + let groups = state.ecs().read_storage::(); + let group_manager = state.ecs().read_resource::(); + if groups.get(entity).map_or(false, |group| { + group_manager + .group_info(*group) + .map_or(false, |g| g.leader == entity) + && groups.get(invitee) == Some(group) + }) { + // Inform of failure + if let Some(client) = clients.get_mut(entity) { + client.notify(ChatType::Meta.server_msg( + "Invite failed, can't invite someone already in your group".to_owned(), + )); } - // Would be cool to do this in agent system (e.g. add an invited - // component to replace the field on Client) - // TODO: move invites to component and make them time out - } else if matches!( - (alignments.get(invitee), agents.get(invitee)), - (Some(comp::Alignment::Npc), Some(_)) - ) { - add_to_group = true; - // Wipe agent state - drop(agents); - let _ = state - .ecs() - .write_storage() - .insert(invitee, comp::Agent::default()); - } else if let Some(client) = clients.get_mut(entity) { - client.notify(ChatType::Meta.server_msg("Invite rejected.".to_owned())); + return; } - if already_has_invite { + let mut pending_invites = state.ecs().write_storage::(); + + // Check if group max size is already reached + // Adding the current number of pending invites + if state + .ecs() + .read_storage() + .get(entity) + .copied() + .and_then(|group| { + // If entity is currently the leader of a full group then they can't invite + // anyone else + group_manager + .group_info(group) + .filter(|i| i.leader == entity) + .map(|i| i.num_members) + }) + .unwrap_or(1) as usize + + pending_invites.get(entity).map_or(0, |p| p.0.len()) + >= max_group_size as usize + { + // Inform inviter that they have reached the group size limit + if let Some(client) = clients.get_mut(entity) { + client.notify( + ChatType::Meta.server_msg( + "Invite failed, pending invites plus current group size have reached \ + the group size limit" + .to_owned(), + ), + ); + } + return; + } + + let agents = state.ecs().read_storage::(); + let mut invites = state.ecs().write_storage::(); + + if invites.contains(invitee) { // Inform inviter that there is already an invite if let Some(client) = clients.get_mut(entity) { client.notify( @@ -83,37 +114,94 @@ pub fn handle_group(server: &mut Server, entity: specs::Entity, manip: GroupMani .server_msg("This player already has a pending invite.".to_owned()), ); } + return; } - if add_to_group { - let mut group_manager = state.ecs().write_resource::(); - group_manager.add_group_member( - entity, - invitee, - &state.ecs().entities(), - &mut state.ecs().write_storage(), - &alignments, - &uids, - |entity, group_change| { - clients - .get_mut(entity) - .and_then(|c| { - group_change - .try_map(|e| uids.get(e).copied()) - .map(|g| (g, c)) - }) - .map(|(g, c)| c.notify(ServerMsg::GroupUpdate(g))); + let mut invite_sent = false; + // Returns true if insertion was succesful + let mut send_invite = || { + match invites.insert(invitee, group::Invite(entity)) { + Err(err) => { + error!("Failed to insert Invite component: {:?}", err); + false }, - ); + Ok(_) => { + match pending_invites.entry(entity) { + Ok(entry) => { + entry + .or_insert_with(|| PendingInvites(Vec::new())) + .0 + .push((invitee, Instant::now() + INVITE_TIMEOUT_DUR)); + invite_sent = true; + true + }, + Err(err) => { + error!( + "Failed to get entry for pending invites component: {:?}", + err + ); + // Cleanup + invites.remove(invitee); + false + }, + } + }, + } + }; + + // If client comp + if let (Some(client), Some(inviter)) = + (clients.get_mut(invitee), uids.get(entity).copied()) + { + if send_invite() { + client.notify(ServerMsg::GroupInvite { + inviter, + timeout: PRESENTED_INVITE_TIMEOUT_DUR, + }); + } + } else if agents.contains(invitee) { + send_invite(); + } else { + if let Some(client) = clients.get_mut(entity) { + client.notify( + ChatType::Meta.server_msg("Can't invite, not a player or npc".to_owned()), + ); + } + } + + // Notify inviter that the invite is pending + if invite_sent { + if let Some(client) = clients.get_mut(entity) { + client.notify(ServerMsg::InvitePending(uid)); + } } }, GroupManip::Accept => { let mut clients = state.ecs().write_storage::(); let uids = state.ecs().read_storage::(); - if let Some(inviter) = clients - .get_mut(entity) - .and_then(|c| c.invited_to_group.take()) - { + let mut invites = state.ecs().write_storage::(); + if let Some(inviter) = invites.remove(entity).and_then(|invite| { + let inviter = invite.0; + let mut pending_invites = state.ecs().write_storage::(); + let pending = &mut pending_invites.get_mut(inviter)?.0; + // Check that inviter has a pending invite and remove it from the list + let invite_index = pending.iter().position(|p| p.0 == entity)?; + pending.swap_remove(invite_index); + // If no pending invites remain remove the component + if pending.is_empty() { + pending_invites.remove(inviter); + } + + Some(inviter) + }) { + if let (Some(client), Some(target)) = + (clients.get_mut(inviter), uids.get(entity).copied()) + { + client.notify(ServerMsg::InviteComplete { + target, + answer: InviteAnswer::Accepted, + }) + } let mut group_manager = state.ecs().write_resource::(); group_manager.add_group_member( inviter, @@ -137,14 +225,30 @@ pub fn handle_group(server: &mut Server, entity: specs::Entity, manip: GroupMani }, GroupManip::Decline => { let mut clients = state.ecs().write_storage::(); - if let Some(inviter) = clients - .get_mut(entity) - .and_then(|c| c.invited_to_group.take()) - { + let uids = state.ecs().read_storage::(); + let mut invites = state.ecs().write_storage::(); + if let Some(inviter) = invites.remove(entity).and_then(|invite| { + let inviter = invite.0; + let mut pending_invites = state.ecs().write_storage::(); + let pending = &mut pending_invites.get_mut(inviter)?.0; + // Check that inviter has a pending invite and remove it from the list + let invite_index = pending.iter().position(|p| p.0 == entity)?; + pending.swap_remove(invite_index); + // If no pending invites remain remove the component + if pending.is_empty() { + pending_invites.remove(inviter); + } + + Some(inviter) + }) { // Inform inviter of rejection - if let Some(client) = clients.get_mut(inviter) { - // TODO: say who declined the invite - client.notify(ChatType::Meta.server_msg("Invite declined.".to_owned())); + if let (Some(client), Some(target)) = + (clients.get_mut(inviter), uids.get(entity).copied()) + { + client.notify(ServerMsg::InviteComplete { + target, + answer: InviteAnswer::Declined, + }) } } }, diff --git a/server/src/events/player.rs b/server/src/events/player.rs index c81482c199..2c9a06e54a 100644 --- a/server/src/events/player.rs +++ b/server/src/events/player.rs @@ -59,7 +59,7 @@ pub fn handle_exit_ingame(server: &mut Server, entity: EcsEntity) { &state.ecs().entities(), &state.ecs().read_storage(), &state.ecs().read_storage(), - // Nothing actually changing + // Nothing actually changing since Uid is transferred |_, _| {}, ); } diff --git a/server/src/lib.rs b/server/src/lib.rs index 9a43cb82db..c0b7c4cea6 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,6 +1,6 @@ #![deny(unsafe_code)] #![allow(clippy::option_map_unit_fn)] -#![feature(drain_filter, option_zip)] +#![feature(bool_to_option, drain_filter, option_zip)] pub mod alias_validator; pub mod chunk_generator; @@ -127,6 +127,7 @@ impl Server { state.ecs_mut().insert(sys::TerrainSyncTimer::default()); state.ecs_mut().insert(sys::TerrainTimer::default()); state.ecs_mut().insert(sys::WaypointTimer::default()); + state.ecs_mut().insert(sys::InviteTimeoutTimer::default()); state.ecs_mut().insert(sys::PersistenceTimer::default()); // System schedulers to control execution of systems @@ -508,12 +509,13 @@ impl Server { .nanos as i64; let terrain_nanos = self.state.ecs().read_resource::().nanos as i64; let waypoint_nanos = self.state.ecs().read_resource::().nanos as i64; + let invite_timeout_nanos = self.state.ecs().read_resource::().nanos as i64; let stats_persistence_nanos = self .state .ecs() .read_resource::() .nanos as i64; - let total_sys_ran_in_dispatcher_nanos = terrain_nanos + waypoint_nanos; + let total_sys_ran_in_dispatcher_nanos = terrain_nanos + waypoint_nanos + invite_timeout_nanos; // Report timing info self.tick_metrics @@ -575,6 +577,10 @@ impl Server { .tick_time .with_label_values(&["waypoint"]) .set(waypoint_nanos); + self.tick_metrics + .tick_time + .with_label_values(&["invite timeout"]) + .set(invite_timeout_nanos); self.tick_metrics .tick_time .with_label_values(&["persistence:stats"]) @@ -656,7 +662,6 @@ impl Server { network_error: std::sync::atomic::AtomicBool::new(false), last_ping: self.state.get_time(), login_msg_sent: false, - invited_to_group: None, }; if self.settings().max_players @@ -685,6 +690,7 @@ impl Server { .create_entity_package(entity, None, None, None), server_info: self.get_server_info(), time_of_day: *self.state.ecs().read_resource(), + max_group_size: self.settings().max_player_group_size, world_map: (WORLD_SIZE.map(|e| e as u32), self.map.clone()), recipe_book: (&*default_recipe_book()).clone(), }); diff --git a/server/src/settings.rs b/server/src/settings.rs index 8bc0c6f3a5..06dec6e3a9 100644 --- a/server/src/settings.rs +++ b/server/src/settings.rs @@ -26,6 +26,7 @@ pub struct ServerSettings { pub persistence_db_dir: String, pub max_view_distance: Option, pub banned_words_files: Vec, + pub max_player_group_size: u32, } impl Default for ServerSettings { @@ -65,6 +66,7 @@ impl Default for ServerSettings { persistence_db_dir: "saves".to_owned(), max_view_distance: Some(30), banned_words_files: Vec::new(), + max_player_group_size: 6, } } } diff --git a/server/src/sys/invite_timeout.rs b/server/src/sys/invite_timeout.rs new file mode 100644 index 0000000000..7a701b03a4 --- /dev/null +++ b/server/src/sys/invite_timeout.rs @@ -0,0 +1,71 @@ +use super::SysTimer; +use crate::client::Client; +use common::{ + comp::group::{Invite, PendingInvites}, + msg::{InviteAnswer, ServerMsg}, + sync::Uid, +}; +use specs::{Entities, Join, ReadStorage, System, Write, WriteStorage}; + +/// This system removes timed out group invites +pub struct Sys; +impl<'a> System<'a> for Sys { + #[allow(clippy::type_complexity)] // TODO: Pending review in #587 + type SystemData = ( + Entities<'a>, + WriteStorage<'a, Invite>, + WriteStorage<'a, PendingInvites>, + WriteStorage<'a, Client>, + ReadStorage<'a, Uid>, + Write<'a, SysTimer>, + ); + + fn run( + &mut self, + (entities, mut invites, mut pending_invites, mut clients, uids, mut timer): Self::SystemData, + ) { + timer.start(); + + let now = std::time::Instant::now(); + + let timed_out_invites = (&entities, &invites) + .join() + .filter_map(|(invitee, Invite(inviter))| { + // Retrieve timeout invite from pending invites + let pending = &mut pending_invites.get_mut(*inviter)?.0; + let index = pending.iter().position(|p| p.0 == invitee)?; + + // Stop if not timed out + if pending[index].1 > now { + return None; + } + + // Remove pending entry + pending.swap_remove(index); + + // If no pending invites remain remove the component + if pending.is_empty() { + pending_invites.remove(*inviter); + } + + // Inform inviter of timeout + if let (Some(client), Some(target)) = + (clients.get_mut(*inviter), uids.get(invitee).copied()) + { + client.notify(ServerMsg::InviteComplete { + target, + answer: InviteAnswer::TimedOut, + }) + } + + Some(invitee) + }) + .collect::>(); + + for entity in timed_out_invites { + invites.remove(entity); + } + + timer.end(); + } +} diff --git a/server/src/sys/mod.rs b/server/src/sys/mod.rs index 5afef7795c..6b98fc9edb 100644 --- a/server/src/sys/mod.rs +++ b/server/src/sys/mod.rs @@ -1,4 +1,5 @@ pub mod entity_sync; +pub mod invite_timeout; pub mod message; pub mod object; pub mod persistence; @@ -21,6 +22,7 @@ pub type SubscriptionTimer = SysTimer; pub type TerrainTimer = SysTimer; pub type TerrainSyncTimer = SysTimer; pub type WaypointTimer = SysTimer; +pub type InviteTimeoutTimer = SysTimer; pub type PersistenceTimer = SysTimer; pub type PersistenceScheduler = SysScheduler; @@ -32,12 +34,14 @@ pub type PersistenceScheduler = SysScheduler; //const TERRAIN_SYNC_SYS: &str = "server_terrain_sync_sys"; const TERRAIN_SYS: &str = "server_terrain_sys"; const WAYPOINT_SYS: &str = "server_waypoint_sys"; +const INVITE_TIMEOUT_SYS: &str = "server_invite_timeout_sys"; const PERSISTENCE_SYS: &str = "server_persistence_sys"; const OBJECT_SYS: &str = "server_object_sys"; pub fn add_server_systems(dispatch_builder: &mut DispatcherBuilder) { dispatch_builder.add(terrain::Sys, TERRAIN_SYS, &[]); dispatch_builder.add(waypoint::Sys, WAYPOINT_SYS, &[]); + dispatch_builder.add(invite_timeout::Sys, INVITE_TIMEOUT_SYS, &[]); dispatch_builder.add(persistence::Sys, PERSISTENCE_SYS, &[]); dispatch_builder.add(object::Sys, OBJECT_SYS, &[]); } diff --git a/voxygen/src/hud/group.rs b/voxygen/src/hud/group.rs index a35a37db83..fd47ac3d6e 100644 --- a/voxygen/src/hud/group.rs +++ b/voxygen/src/hud/group.rs @@ -182,23 +182,22 @@ impl<'a> Widget for Group<'a> { .crop_kids() .set(state.ids.bg, ui); } - if open_invite.is_some() { + if let Some((_, timeout_start, timeout_dur)) = open_invite { // Group Menu button Button::image(self.imgs.group_icon) .w_h(49.0, 26.0) .bottom_left_with_margins_on(ui.window, 10.0, 490.0) .set(state.ids.group_button, ui); // Show timeout bar - let max_time = 90.0; - let time = 50.0; - let progress_perc = time / max_time; + let timeout_progress = + 1.0 - timeout_start.elapsed().as_secs_f32() / timeout_dur.as_secs_f32(); Image::new(self.imgs.progress_frame) .w_h(100.0, 10.0) .middle_of(state.ids.bg) .color(Some(UI_MAIN)) .set(state.ids.timeout_bg, ui); Image::new(self.imgs.progress) - .w_h(98.0 * progress_perc, 8.0) + .w_h(98.0 * timeout_progress as f64, 8.0) .top_left_with_margins_on(state.ids.timeout_bg, 1.0, 1.0) .color(Some(UI_HIGHLIGHT_0)) .set(state.ids.timeout, ui); @@ -613,7 +612,7 @@ impl<'a> Widget for Group<'a> { // into the maximum group size. } } - if let Some(invite_uid) = open_invite { + if let Some((invite_uid, _, _)) = open_invite { self.show.group = true; // Auto open group menu // TODO: add group name here too // Invite text diff --git a/voxygen/src/hud/social.rs b/voxygen/src/hud/social.rs index e1158019a4..c1dc3a7eb3 100644 --- a/voxygen/src/hud/social.rs +++ b/voxygen/src/hud/social.rs @@ -8,7 +8,7 @@ use crate::{ ui::{fonts::ConrodVoxygenFonts, ImageFrame, Tooltip, TooltipManager, Tooltipable}, }; use client::{self, Client}; -use common::sync::Uid; +use common::{comp::group, sync::Uid}; use conrod_core::{ color, widget::{self, Button, Image, Rectangle, Scrollbar, Text}, @@ -467,68 +467,90 @@ impl<'a> Widget for Social<'a> { } // Invite Button - let selected_ingame = state - .selected_uid - .as_ref() - .map(|(s, _)| *s) - .filter(|selected| { - self.client - .player_list - .get(selected) - .map_or(false, |selected_player| { - selected_player.is_online && selected_player.character.is_some() + let is_leader_or_not_in_group = self + .client + .group_info() + .map_or(true, |(_, l_uid)| self.client.uid() == Some(l_uid)); + + let current_members = self + .client + .group_members() + .iter() + .filter(|(_, role)| matches!(role, group::Role::Member)) + .count() + + 1; + let current_invites = self.client.pending_invites().len(); + let max_members = self.client.max_group_size() as usize; + let group_not_full = current_members + current_invites < max_members; + let selected_to_invite = (is_leader_or_not_in_group && group_not_full) + .then(|| { + state + .selected_uid + .as_ref() + .map(|(s, _)| *s) + .filter(|selected| { + self.client + .player_list + .get(selected) + .map_or(false, |selected_player| { + selected_player.is_online && selected_player.character.is_some() + }) + }) + .or_else(|| { + self.selected_entity + .and_then(|s| self.client.state().read_component_copied(s.0)) + }) + .filter(|selected| { + // Prevent inviting entities already in the same group + !self.client.group_members().contains_key(selected) }) }) - .or_else(|| { - self.selected_entity - .and_then(|s| self.client.state().read_component_copied(s.0)) - }); - // TODO: Prevent inviting players with the same group uid - // TODO: Show current amount of group members as a tooltip for the invite button - // if the player is the group leader TODO: Grey out the invite - // button if the group has 6/6 members - let current_members = 4; - let tooltip_txt = if selected_ingame.is_some() { - format!( - "{}/6 {}", - ¤t_members, - &self.localized_strings.get("hud.group.members") - ) - } else { - (&self.localized_strings.get("hud.group.members")).to_string() - }; - if Button::image(self.imgs.button) + .flatten(); + + let invite_button = Button::image(self.imgs.button) .w_h(106.0, 26.0) .bottom_right_with_margins_on(state.ids.frame, 9.0, 7.0) - .hover_image(if selected_ingame.is_some() { + .hover_image(if selected_to_invite.is_some() { self.imgs.button_hover } else { self.imgs.button }) - .press_image(if selected_ingame.is_some() { + .press_image(if selected_to_invite.is_some() { self.imgs.button_press } else { self.imgs.button }) - .label(&self.localized_strings.get("hud.group.invite")) + .label(self.localized_strings.get("hud.group.invite")) .label_y(conrod_core::position::Relative::Scalar(3.0)) - .label_color(if selected_ingame.is_some() { + .label_color(if selected_to_invite.is_some() { TEXT_COLOR } else { TEXT_COLOR_3 }) - .image_color(if selected_ingame.is_some() { + .image_color(if selected_to_invite.is_some() { TEXT_COLOR } else { TEXT_COLOR_3 }) .label_font_size(self.fonts.cyri.scale(15)) - .label_font_id(self.fonts.cyri.conrod_id) - .with_tooltip(self.tooltip_manager, &tooltip_txt, "", &button_tooltip) - .set(state.ids.invite_button, ui) - .was_clicked() + .label_font_id(self.fonts.cyri.conrod_id); + + if if self.client.group_info().is_some() { + let tooltip_txt = format!( + "{}/{} {}", + current_members + current_invites, + max_members, + &self.localized_strings.get("hud.group.members") + ); + invite_button + .with_tooltip(self.tooltip_manager, &tooltip_txt, "", &button_tooltip) + .set(state.ids.invite_button, ui) + } else { + invite_button.set(state.ids.invite_button, ui) + } + .was_clicked() { - if let Some(uid) = selected_ingame { + if let Some(uid) = selected_to_invite { events.push(Event::Invite(uid)); state.update(|s| { s.selected_uid = None;