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
This commit is contained in:
Imbris 2020-08-06 21:59:28 -04:00 committed by Monty Marz
parent def68302c7
commit 390d289d35
17 changed files with 467 additions and 172 deletions

View File

@ -4,4 +4,6 @@ rustflags = [
] ]
[alias] [alias]
generate = "run --package tools --" generate = "run --package tools --"
test-server = "run --bin veloren-server-cli --no-default-features"

32
Cargo.lock generated
View File

@ -219,16 +219,6 @@ dependencies = [
"winapi 0.3.8", "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]] [[package]]
name = "auth-common" name = "auth-common"
version = "0.1.0" version = "0.1.0"
@ -239,26 +229,12 @@ dependencies = [
"uuid", "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]] [[package]]
name = "authc" name = "authc"
version = "1.0.0" version = "1.0.0"
source = "git+https://gitlab.com/veloren/auth.git?rev=b943c85e4a38f5ec60cd18c34c73097640162bfe#b943c85e4a38f5ec60cd18c34c73097640162bfe" source = "git+https://gitlab.com/veloren/auth.git?rev=b943c85e4a38f5ec60cd18c34c73097640162bfe#b943c85e4a38f5ec60cd18c34c73097640162bfe"
dependencies = [ dependencies = [
"auth-common 0.1.0 (git+https://gitlab.com/veloren/auth.git?rev=b943c85e4a38f5ec60cd18c34c73097640162bfe)", "auth-common",
"fxhash", "fxhash",
"hex", "hex",
"rust-argon2 0.8.2", "rust-argon2 0.8.2",
@ -4625,7 +4601,7 @@ dependencies = [
name = "veloren-client" name = "veloren-client"
version = "0.6.0" version = "0.6.0"
dependencies = [ dependencies = [
"authc 1.0.0 (git+https://gitlab.com/veloren/auth.git?rev=b943c85e4a38f5ec60cd18c34c73097640162bfe)", "authc",
"byteorder 1.3.4", "byteorder 1.3.4",
"futures-executor", "futures-executor",
"futures-timer", "futures-timer",
@ -4646,7 +4622,7 @@ name = "veloren-common"
version = "0.6.0" version = "0.6.0"
dependencies = [ dependencies = [
"arraygen", "arraygen",
"authc 1.0.0 (git+https://gitlab.com/veloren/auth.git?rev=223a4097f7ebc8d451936dccb5e6517194bbf086)", "authc",
"criterion", "criterion",
"crossbeam", "crossbeam",
"dot_vox", "dot_vox",
@ -4674,7 +4650,7 @@ dependencies = [
name = "veloren-server" name = "veloren-server"
version = "0.6.0" version = "0.6.0"
dependencies = [ dependencies = [
"authc 1.0.0 (git+https://gitlab.com/veloren/auth.git?rev=b943c85e4a38f5ec60cd18c34c73097640162bfe)", "authc",
"chrono", "chrono",
"crossbeam", "crossbeam",
"diesel", "diesel",

View File

@ -23,7 +23,7 @@ use common::{
msg::{ msg::{
validate_chat_msg, ChatMsgValidationError, ClientMsg, ClientState, Notification, validate_chat_msg, ChatMsgValidationError, ClientMsg, ClientState, Notification,
PlayerInfo, PlayerListUpdate, RegisterError, RequestStateError, ServerInfo, ServerMsg, PlayerInfo, PlayerListUpdate, RegisterError, RequestStateError, ServerInfo, ServerMsg,
MAX_BYTES_CHAT_MSG, MAX_BYTES_CHAT_MSG, InviteAnswer,
}, },
recipe::RecipeBook, recipe::RecipeBook,
state::State, state::State,
@ -79,9 +79,14 @@ pub struct Client {
recipe_book: RecipeBook, recipe_book: RecipeBook,
available_recipes: HashSet<String>, available_recipes: HashSet<String>,
group_invite: Option<Uid>, 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<Uid>, group_leader: Option<Uid>,
// Note: potentially representable as a client only component
group_members: HashMap<Uid, group::Role>, group_members: HashMap<Uid, group::Role>,
// Pending invites that this client has sent out
pending_invites: HashSet<Uid>,
_network: Network, _network: Network,
participant: Option<Participant>, participant: Option<Participant>,
@ -130,13 +135,14 @@ impl Client {
let mut stream = block_on(participant.open(10, PROMISES_ORDERED | PROMISES_CONSISTENCY))?; let mut stream = block_on(participant.open(10, PROMISES_ORDERED | PROMISES_CONSISTENCY))?;
// Wait for initial sync // 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 { loop {
match stream.recv().await? { match stream.recv().await? {
ServerMsg::InitialSync { ServerMsg::InitialSync {
entity_package, entity_package,
server_info, server_info,
time_of_day, time_of_day,
max_group_size,
world_map: (map_size, world_map), world_map: (map_size, world_map),
recipe_book, recipe_book,
} => { } => {
@ -188,6 +194,7 @@ impl Client {
server_info, server_info,
(world_map, map_size), (world_map, map_size),
recipe_book, recipe_book,
max_group_size,
)); ));
}, },
ServerMsg::TooManyPlayers => break Err(Error::TooManyPlayers), ServerMsg::TooManyPlayers => break Err(Error::TooManyPlayers),
@ -212,14 +219,16 @@ impl Client {
server_info, server_info,
world_map, world_map,
player_list: HashMap::new(), player_list: HashMap::new(),
group_members: HashMap::new(),
character_list: CharacterList::default(), character_list: CharacterList::default(),
active_character_id: None, active_character_id: None,
recipe_book, recipe_book,
available_recipes: HashSet::default(), available_recipes: HashSet::default(),
max_group_size,
group_invite: None, group_invite: None,
group_leader: None, group_leader: None,
group_members: HashMap::new(),
pending_invites: HashSet::new(),
_network: network, _network: network,
participant: Some(participant), participant: Some(participant),
@ -432,7 +441,9 @@ impl Client {
.unwrap(); .unwrap();
} }
pub fn group_invite(&self) -> Option<Uid> { 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)> { pub fn group_info(&self) -> Option<(String, Uid)> {
self.group_leader.map(|l| ("Group".into(), l)) // TODO self.group_leader.map(|l| ("Group".into(), l)) // TODO
@ -440,6 +451,8 @@ impl Client {
pub fn group_members(&self) -> &HashMap<Uid, group::Role> { &self.group_members } pub fn group_members(&self) -> &HashMap<Uid, group::Role> { &self.group_members }
pub fn pending_invites(&self) -> &HashSet<Uid> { &self.pending_invites }
pub fn send_group_invite(&mut self, invitee: Uid) { pub fn send_group_invite(&mut self, invitee: Uid) {
self.singleton_stream self.singleton_stream
.send(ClientMsg::ControlEvent(ControlEvent::GroupManip( .send(ClientMsg::ControlEvent(ControlEvent::GroupManip(
@ -758,6 +771,10 @@ impl Client {
frontend_events.append(&mut self.handle_new_messages()?); frontend_events.append(&mut self.handle_new_messages()?);
// 3) Update client local data // 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 // 4) Tick the client's LocalState
self.state.tick(dt, add_foreign_systems, true); self.state.tick(dt, add_foreign_systems, true);
@ -1046,9 +1063,28 @@ impl Client {
}, },
} }
}, },
ServerMsg::GroupInvite(uid) => { ServerMsg::GroupInvite { inviter, timeout } => {
self.group_invite = Some(uid); 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 => { ServerMsg::Ping => {
self.singleton_stream.send(ClientMsg::Pong)?; self.singleton_stream.send(ClientMsg::Pong)?;
}, },

View File

@ -9,9 +9,11 @@ use tracing::{error, warn};
// Primitive group system // Primitive group system
// Shortcomings include: // Shortcomings include:
// - no support for more complex group structures // - 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 // - relies on careful management of groups to maintain a valid state
// - the possesion rod could probably wreck this // - 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)] #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Group(u32); pub struct Group(u32);
@ -26,11 +28,26 @@ impl Component for Group {
type Storage = FlaggedStorage<Self, IdvStorage<Self>>; type Storage = FlaggedStorage<Self, IdvStorage<Self>>;
} }
pub struct Invite(pub specs::Entity);
impl Component for Invite {
type Storage = IdvStorage<Self>;
}
// 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<Self>;
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct GroupInfo { pub struct GroupInfo {
// TODO: what about enemy groups, either the leader will constantly change because they have to // 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 // be loaded or we create a dummy entity or this needs to be optional
pub leader: specs::Entity, pub leader: specs::Entity,
// Number of group members (excluding pets)
pub num_members: u32,
// Name of the group
pub name: String, pub name: String,
} }
@ -134,9 +151,14 @@ impl GroupManager {
self.groups.get(group.0 as usize) 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 { Group(self.groups.insert(GroupInfo {
leader, leader,
num_members,
name: "Group".into(), name: "Group".into(),
}) as u32) }) as u32)
} }
@ -204,15 +226,20 @@ impl GroupManager {
None => None, None => None,
}; };
let group = group.unwrap_or_else(|| { let group = if let Some(group) = group {
let new_group = self.create_group(leader); // 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 // 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 // still exist Note: if there is an issue replace with a warn
groups.insert(leader, new_group).unwrap(); groups.insert(leader, new_group).unwrap();
// Inform // Inform
notifier(leader, ChangeNotification::NewLeader(leader)); notifier(leader, ChangeNotification::NewLeader(leader));
new_group new_group
}); };
let new_pets = pets(new_member, new_member_uid, alignments, entities); let new_pets = pets(new_member, new_member_uid, alignments, entities);
@ -256,7 +283,7 @@ impl GroupManager {
let group = match groups.get(owner).copied() { let group = match groups.get(owner).copied() {
Some(group) => group, Some(group) => group,
None => { None => {
let new_group = self.create_group(owner); let new_group = self.create_group(owner, 1);
groups.insert(owner, new_group).unwrap(); groups.insert(owner, new_group).unwrap();
// Inform // Inform
notifier(owner, ChangeNotification::NewLeader(owner)); notifier(owner, ChangeNotification::NewLeader(owner));
@ -348,7 +375,7 @@ impl GroupManager {
(entities, uids, &*groups, alignments.maybe()) (entities, uids, &*groups, alignments.maybe())
.join() .join()
.filter(|(e, _, g, _)| **g == group && (!to_be_deleted || *e == member)) .filter(|(e, _, g, _)| **g == group && !(to_be_deleted && *e == member))
.fold( .fold(
HashMap::<Uid, (Option<specs::Entity>, Vec<specs::Entity>)>::new(), HashMap::<Uid, (Option<specs::Entity>, Vec<specs::Entity>)>::new(),
|mut acc, (e, uid, _, alignment)| { |mut acc, (e, uid, _, alignment)| {
@ -356,9 +383,11 @@ impl GroupManager {
Alignment::Owned(owner) if uid != owner => Some(owner), Alignment::Owned(owner) if uid != owner => Some(owner),
_ => None, _ => None,
}) { }) {
// A pet
// Assumes owner will be in the group // Assumes owner will be in the group
acc.entry(*owner).or_default().1.push(e); acc.entry(*owner).or_default().1.push(e);
} else { } else {
// Not a pet
acc.entry(*uid).or_default().0 = Some(e); acc.entry(*uid).or_default().0 = Some(e);
} }
@ -375,7 +404,7 @@ impl GroupManager {
members.push((owner, Role::Member)); members.push((owner, Role::Member));
// New group // New group
let new_group = self.create_group(owner); let new_group = self.create_group(owner, 1);
for (member, _) in &members { for (member, _) in &members {
groups.insert(*member, new_group).unwrap(); groups.insert(*member, new_group).unwrap();
} }
@ -401,7 +430,7 @@ impl GroupManager {
let leaving_member_uid = if let Some(uid) = uids.get(member) { let leaving_member_uid = if let Some(uid) = uids.get(member) {
*uid *uid
} else { } else {
error!("Failed to retrieve uid for the new group member"); error!("Failed to retrieve uid for the leaving member");
return; return;
}; };
@ -409,7 +438,7 @@ impl GroupManager {
// If pets and not about to be deleted form new group // If pets and not about to be deleted form new group
if !leaving_pets.is_empty() && !to_be_deleted { 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 { notifier(member, ChangeNotification::NewGroup {
leader: member, 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 // Inform remaining members
let mut num_members = 0; members(group, &*groups, entities, alignments, uids).for_each(
members(group, &*groups, entities, alignments, uids).for_each(|(e, role)| { |(e, role)| match role {
num_members += 1;
match role {
Role::Member => { Role::Member => {
notifier(e, ChangeNotification::Removed(member)); notifier(e, ChangeNotification::Removed(member));
leaving_pets.iter().for_each(|p| { leaving_pets.iter().for_each(|p| {
@ -445,16 +473,16 @@ impl GroupManager {
}) })
}, },
Role::Pet => {}, Role::Pet => {},
} },
}); );
// If leader is the last one left then disband the group // If leader is the last one left then disband the group
// Assumes last member is the leader // Assumes last member is the leader
if num_members == 1 { if info.num_members == 1 {
let leader = info.leader; let leader = info.leader;
self.remove_group(group); self.remove_group(group);
groups.remove(leader); groups.remove(leader);
notifier(leader, ChangeNotification::NoGroup); notifier(leader, ChangeNotification::NoGroup);
} else if num_members == 0 { } else if info.num_members == 0 {
error!("Somehow group has no members") error!("Somehow group has no members")
} }
} }

View File

@ -7,7 +7,7 @@ pub use self::{
client::ClientMsg, client::ClientMsg,
ecs_packet::EcsCompPacket, ecs_packet::EcsCompPacket,
server::{ server::{
CharacterInfo, Notification, PlayerInfo, PlayerListUpdate, RegisterError, CharacterInfo, InviteAnswer, Notification, PlayerInfo, PlayerListUpdate, RegisterError,
RequestStateError, ServerInfo, ServerMsg, RequestStateError, ServerInfo, ServerMsg,
}, },
}; };

View File

@ -47,6 +47,13 @@ pub struct CharacterInfo {
pub level: u32, pub level: u32,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum InviteAnswer {
Accepted,
Declined,
TimedOut,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Notification { pub enum Notification {
WaypointSaved, WaypointSaved,
@ -59,6 +66,7 @@ pub enum ServerMsg {
entity_package: sync::EntityPackage<EcsCompPacket>, entity_package: sync::EntityPackage<EcsCompPacket>,
server_info: ServerInfo, server_info: ServerInfo,
time_of_day: state::TimeOfDay, time_of_day: state::TimeOfDay,
max_group_size: u32,
world_map: (Vec2<u32>, Vec<u32>), world_map: (Vec2<u32>, Vec<u32>),
recipe_book: RecipeBook, recipe_book: RecipeBook,
}, },
@ -70,7 +78,21 @@ pub enum ServerMsg {
CharacterActionError(String), CharacterActionError(String),
PlayerListUpdate(PlayerListUpdate), PlayerListUpdate(PlayerListUpdate),
GroupUpdate(comp::group::ChangeNotification<sync::Uid>), GroupUpdate(comp::group::ChangeNotification<sync::Uid>),
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<ClientState, (RequestStateError, ClientState)>), StateAnswer(Result<ClientState, (RequestStateError, ClientState)>),
/// Trigger cleanup for when the client goes back to the `Registered` state /// Trigger cleanup for when the client goes back to the `Registered` state
/// from an ingame state /// from an ingame state

View File

@ -158,6 +158,8 @@ impl State {
ecs.register::<comp::ItemDrop>(); ecs.register::<comp::ItemDrop>();
ecs.register::<comp::ChatMode>(); ecs.register::<comp::ChatMode>();
ecs.register::<comp::Faction>(); ecs.register::<comp::Faction>();
ecs.register::<comp::group::Invite>();
ecs.register::<comp::group::PendingInvites>();
// Register synced resources used by the ECS. // Register synced resources used by the ECS.
ecs.insert(TimeOfDay(0.0)); ecs.insert(TimeOfDay(0.0));

View File

@ -3,9 +3,11 @@ use crate::{
self, self,
agent::Activity, agent::Activity,
group, group,
group::Invite,
item::{tool::ToolKind, ItemKind}, item::{tool::ToolKind, ItemKind},
Agent, Alignment, Body, CharacterState, ControlAction, Controller, Loadout, MountState, Agent, Alignment, Body, CharacterState, ControlAction, ControlEvent, Controller,
Ori, PhysicsState, Pos, Scale, Stats, UnresolvedChatMsg, Vel, GroupManip, Loadout, MountState, Ori, PhysicsState, Pos, Scale, Stats, UnresolvedChatMsg,
Vel,
}, },
event::{EventBus, ServerEvent}, event::{EventBus, ServerEvent},
path::{Chaser, TraversalConfig}, path::{Chaser, TraversalConfig},
@ -49,6 +51,7 @@ impl<'a> System<'a> for Sys {
WriteStorage<'a, Agent>, WriteStorage<'a, Agent>,
WriteStorage<'a, Controller>, WriteStorage<'a, Controller>,
ReadStorage<'a, MountState>, ReadStorage<'a, MountState>,
ReadStorage<'a, Invite>,
); );
#[allow(clippy::or_fun_call)] // TODO: Pending review in #587 #[allow(clippy::or_fun_call)] // TODO: Pending review in #587
@ -77,6 +80,7 @@ impl<'a> System<'a> for Sys {
mut agents, mut agents,
mut controllers, mut controllers,
mount_states, mount_states,
invites,
): Self::SystemData, ): Self::SystemData,
) { ) {
for ( 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.move_dir.map(|e| !e.is_nan()).reduce_and());
debug_assert!(inputs.look_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));
}
}
} }
} }

View File

@ -18,7 +18,6 @@ pub struct Client {
pub network_error: AtomicBool, pub network_error: AtomicBool,
pub last_ping: f64, pub last_ping: f64,
pub login_msg_sent: bool, pub login_msg_sent: bool,
pub invited_to_group: Option<specs::Entity>,
} }
impl Component for Client { impl Component for Client {

View File

@ -2,18 +2,25 @@ use crate::{client::Client, Server};
use common::{ use common::{
comp::{ comp::{
self, self,
group::{self, GroupManager}, group::{self, Group, GroupManager, Invite, PendingInvites},
ChatType, GroupManip, ChatType, GroupManip,
}, },
msg::ServerMsg, msg::{InviteAnswer, ServerMsg},
sync, sync,
sync::WorldSyncExt, sync::WorldSyncExt,
}; };
use specs::world::WorldExt; 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 // TODO: turn chat messages into enums
pub fn handle_group(server: &mut Server, entity: specs::Entity, manip: GroupManip) { 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(); let state = server.state_mut();
match manip { match manip {
@ -44,38 +51,62 @@ pub fn handle_group(server: &mut Server, entity: specs::Entity, manip: GroupMani
return; return;
} }
let alignments = state.ecs().read_storage::<comp::Alignment>(); // Disallow inviting entity that is already in your group
let agents = state.ecs().read_storage::<comp::Agent>(); let groups = state.ecs().read_storage::<Group>();
let mut already_has_invite = false; let group_manager = state.ecs().read_resource::<GroupManager>();
let mut add_to_group = false; if groups.get(entity).map_or(false, |group| {
// If client comp group_manager
if let (Some(client), Some(inviter_uid)) = (clients.get_mut(invitee), uids.get(entity)) .group_info(*group)
{ .map_or(false, |g| g.leader == entity)
if client.invited_to_group.is_some() { && groups.get(invitee) == Some(group)
already_has_invite = true; }) {
} else { // Inform of failure
client.notify(ServerMsg::GroupInvite(*inviter_uid)); if let Some(client) = clients.get_mut(entity) {
client.invited_to_group = Some(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 return;
// 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()));
} }
if already_has_invite { let mut pending_invites = state.ecs().write_storage::<PendingInvites>();
// 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::<comp::Agent>();
let mut invites = state.ecs().write_storage::<Invite>();
if invites.contains(invitee) {
// Inform inviter that there is already an invite // Inform inviter that there is already an invite
if let Some(client) = clients.get_mut(entity) { if let Some(client) = clients.get_mut(entity) {
client.notify( 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()), .server_msg("This player already has a pending invite.".to_owned()),
); );
} }
return;
} }
if add_to_group { let mut invite_sent = false;
let mut group_manager = state.ecs().write_resource::<GroupManager>(); // Returns true if insertion was succesful
group_manager.add_group_member( let mut send_invite = || {
entity, match invites.insert(invitee, group::Invite(entity)) {
invitee, Err(err) => {
&state.ecs().entities(), error!("Failed to insert Invite component: {:?}", err);
&mut state.ecs().write_storage(), false
&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)));
}, },
); 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 => { GroupManip::Accept => {
let mut clients = state.ecs().write_storage::<Client>(); let mut clients = state.ecs().write_storage::<Client>();
let uids = state.ecs().read_storage::<sync::Uid>(); let uids = state.ecs().read_storage::<sync::Uid>();
if let Some(inviter) = clients let mut invites = state.ecs().write_storage::<Invite>();
.get_mut(entity) if let Some(inviter) = invites.remove(entity).and_then(|invite| {
.and_then(|c| c.invited_to_group.take()) let inviter = invite.0;
{ let mut pending_invites = state.ecs().write_storage::<PendingInvites>();
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::<GroupManager>(); let mut group_manager = state.ecs().write_resource::<GroupManager>();
group_manager.add_group_member( group_manager.add_group_member(
inviter, inviter,
@ -137,14 +225,30 @@ pub fn handle_group(server: &mut Server, entity: specs::Entity, manip: GroupMani
}, },
GroupManip::Decline => { GroupManip::Decline => {
let mut clients = state.ecs().write_storage::<Client>(); let mut clients = state.ecs().write_storage::<Client>();
if let Some(inviter) = clients let uids = state.ecs().read_storage::<sync::Uid>();
.get_mut(entity) let mut invites = state.ecs().write_storage::<Invite>();
.and_then(|c| c.invited_to_group.take()) if let Some(inviter) = invites.remove(entity).and_then(|invite| {
{ let inviter = invite.0;
let mut pending_invites = state.ecs().write_storage::<PendingInvites>();
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 // Inform inviter of rejection
if let Some(client) = clients.get_mut(inviter) { if let (Some(client), Some(target)) =
// TODO: say who declined the invite (clients.get_mut(inviter), uids.get(entity).copied())
client.notify(ChatType::Meta.server_msg("Invite declined.".to_owned())); {
client.notify(ServerMsg::InviteComplete {
target,
answer: InviteAnswer::Declined,
})
} }
} }
}, },

View File

@ -59,7 +59,7 @@ pub fn handle_exit_ingame(server: &mut Server, entity: EcsEntity) {
&state.ecs().entities(), &state.ecs().entities(),
&state.ecs().read_storage(), &state.ecs().read_storage(),
&state.ecs().read_storage(), &state.ecs().read_storage(),
// Nothing actually changing // Nothing actually changing since Uid is transferred
|_, _| {}, |_, _| {},
); );
} }

View File

@ -1,6 +1,6 @@
#![deny(unsafe_code)] #![deny(unsafe_code)]
#![allow(clippy::option_map_unit_fn)] #![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 alias_validator;
pub mod chunk_generator; pub mod chunk_generator;
@ -127,6 +127,7 @@ impl Server {
state.ecs_mut().insert(sys::TerrainSyncTimer::default()); state.ecs_mut().insert(sys::TerrainSyncTimer::default());
state.ecs_mut().insert(sys::TerrainTimer::default()); state.ecs_mut().insert(sys::TerrainTimer::default());
state.ecs_mut().insert(sys::WaypointTimer::default()); state.ecs_mut().insert(sys::WaypointTimer::default());
state.ecs_mut().insert(sys::InviteTimeoutTimer::default());
state.ecs_mut().insert(sys::PersistenceTimer::default()); state.ecs_mut().insert(sys::PersistenceTimer::default());
// System schedulers to control execution of systems // System schedulers to control execution of systems
@ -508,12 +509,13 @@ impl Server {
.nanos as i64; .nanos as i64;
let terrain_nanos = self.state.ecs().read_resource::<sys::TerrainTimer>().nanos as i64; let terrain_nanos = self.state.ecs().read_resource::<sys::TerrainTimer>().nanos as i64;
let waypoint_nanos = self.state.ecs().read_resource::<sys::WaypointTimer>().nanos as i64; let waypoint_nanos = self.state.ecs().read_resource::<sys::WaypointTimer>().nanos as i64;
let invite_timeout_nanos = self.state.ecs().read_resource::<sys::InviteTimeoutTimer>().nanos as i64;
let stats_persistence_nanos = self let stats_persistence_nanos = self
.state .state
.ecs() .ecs()
.read_resource::<sys::PersistenceTimer>() .read_resource::<sys::PersistenceTimer>()
.nanos as i64; .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 // Report timing info
self.tick_metrics self.tick_metrics
@ -575,6 +577,10 @@ impl Server {
.tick_time .tick_time
.with_label_values(&["waypoint"]) .with_label_values(&["waypoint"])
.set(waypoint_nanos); .set(waypoint_nanos);
self.tick_metrics
.tick_time
.with_label_values(&["invite timeout"])
.set(invite_timeout_nanos);
self.tick_metrics self.tick_metrics
.tick_time .tick_time
.with_label_values(&["persistence:stats"]) .with_label_values(&["persistence:stats"])
@ -656,7 +662,6 @@ impl Server {
network_error: std::sync::atomic::AtomicBool::new(false), network_error: std::sync::atomic::AtomicBool::new(false),
last_ping: self.state.get_time(), last_ping: self.state.get_time(),
login_msg_sent: false, login_msg_sent: false,
invited_to_group: None,
}; };
if self.settings().max_players if self.settings().max_players
@ -685,6 +690,7 @@ impl Server {
.create_entity_package(entity, None, None, None), .create_entity_package(entity, None, None, None),
server_info: self.get_server_info(), server_info: self.get_server_info(),
time_of_day: *self.state.ecs().read_resource(), 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()), world_map: (WORLD_SIZE.map(|e| e as u32), self.map.clone()),
recipe_book: (&*default_recipe_book()).clone(), recipe_book: (&*default_recipe_book()).clone(),
}); });

View File

@ -26,6 +26,7 @@ pub struct ServerSettings {
pub persistence_db_dir: String, pub persistence_db_dir: String,
pub max_view_distance: Option<u32>, pub max_view_distance: Option<u32>,
pub banned_words_files: Vec<PathBuf>, pub banned_words_files: Vec<PathBuf>,
pub max_player_group_size: u32,
} }
impl Default for ServerSettings { impl Default for ServerSettings {
@ -65,6 +66,7 @@ impl Default for ServerSettings {
persistence_db_dir: "saves".to_owned(), persistence_db_dir: "saves".to_owned(),
max_view_distance: Some(30), max_view_distance: Some(30),
banned_words_files: Vec::new(), banned_words_files: Vec::new(),
max_player_group_size: 6,
} }
} }
} }

View File

@ -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<Self>>,
);
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::<Vec<_>>();
for entity in timed_out_invites {
invites.remove(entity);
}
timer.end();
}
}

View File

@ -1,4 +1,5 @@
pub mod entity_sync; pub mod entity_sync;
pub mod invite_timeout;
pub mod message; pub mod message;
pub mod object; pub mod object;
pub mod persistence; pub mod persistence;
@ -21,6 +22,7 @@ pub type SubscriptionTimer = SysTimer<subscription::Sys>;
pub type TerrainTimer = SysTimer<terrain::Sys>; pub type TerrainTimer = SysTimer<terrain::Sys>;
pub type TerrainSyncTimer = SysTimer<terrain_sync::Sys>; pub type TerrainSyncTimer = SysTimer<terrain_sync::Sys>;
pub type WaypointTimer = SysTimer<waypoint::Sys>; pub type WaypointTimer = SysTimer<waypoint::Sys>;
pub type InviteTimeoutTimer = SysTimer<invite_timeout::Sys>;
pub type PersistenceTimer = SysTimer<persistence::Sys>; pub type PersistenceTimer = SysTimer<persistence::Sys>;
pub type PersistenceScheduler = SysScheduler<persistence::Sys>; pub type PersistenceScheduler = SysScheduler<persistence::Sys>;
@ -32,12 +34,14 @@ pub type PersistenceScheduler = SysScheduler<persistence::Sys>;
//const TERRAIN_SYNC_SYS: &str = "server_terrain_sync_sys"; //const TERRAIN_SYNC_SYS: &str = "server_terrain_sync_sys";
const TERRAIN_SYS: &str = "server_terrain_sys"; const TERRAIN_SYS: &str = "server_terrain_sys";
const WAYPOINT_SYS: &str = "server_waypoint_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 PERSISTENCE_SYS: &str = "server_persistence_sys";
const OBJECT_SYS: &str = "server_object_sys"; const OBJECT_SYS: &str = "server_object_sys";
pub fn add_server_systems(dispatch_builder: &mut DispatcherBuilder) { pub fn add_server_systems(dispatch_builder: &mut DispatcherBuilder) {
dispatch_builder.add(terrain::Sys, TERRAIN_SYS, &[]); dispatch_builder.add(terrain::Sys, TERRAIN_SYS, &[]);
dispatch_builder.add(waypoint::Sys, WAYPOINT_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(persistence::Sys, PERSISTENCE_SYS, &[]);
dispatch_builder.add(object::Sys, OBJECT_SYS, &[]); dispatch_builder.add(object::Sys, OBJECT_SYS, &[]);
} }

View File

@ -182,23 +182,22 @@ impl<'a> Widget for Group<'a> {
.crop_kids() .crop_kids()
.set(state.ids.bg, ui); .set(state.ids.bg, ui);
} }
if open_invite.is_some() { if let Some((_, timeout_start, timeout_dur)) = open_invite {
// Group Menu button // Group Menu button
Button::image(self.imgs.group_icon) Button::image(self.imgs.group_icon)
.w_h(49.0, 26.0) .w_h(49.0, 26.0)
.bottom_left_with_margins_on(ui.window, 10.0, 490.0) .bottom_left_with_margins_on(ui.window, 10.0, 490.0)
.set(state.ids.group_button, ui); .set(state.ids.group_button, ui);
// Show timeout bar // Show timeout bar
let max_time = 90.0; let timeout_progress =
let time = 50.0; 1.0 - timeout_start.elapsed().as_secs_f32() / timeout_dur.as_secs_f32();
let progress_perc = time / max_time;
Image::new(self.imgs.progress_frame) Image::new(self.imgs.progress_frame)
.w_h(100.0, 10.0) .w_h(100.0, 10.0)
.middle_of(state.ids.bg) .middle_of(state.ids.bg)
.color(Some(UI_MAIN)) .color(Some(UI_MAIN))
.set(state.ids.timeout_bg, ui); .set(state.ids.timeout_bg, ui);
Image::new(self.imgs.progress) 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) .top_left_with_margins_on(state.ids.timeout_bg, 1.0, 1.0)
.color(Some(UI_HIGHLIGHT_0)) .color(Some(UI_HIGHLIGHT_0))
.set(state.ids.timeout, ui); .set(state.ids.timeout, ui);
@ -613,7 +612,7 @@ impl<'a> Widget for Group<'a> {
// into the maximum group size. // 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 self.show.group = true; // Auto open group menu
// TODO: add group name here too // TODO: add group name here too
// Invite text // Invite text

View File

@ -8,7 +8,7 @@ use crate::{
ui::{fonts::ConrodVoxygenFonts, ImageFrame, Tooltip, TooltipManager, Tooltipable}, ui::{fonts::ConrodVoxygenFonts, ImageFrame, Tooltip, TooltipManager, Tooltipable},
}; };
use client::{self, Client}; use client::{self, Client};
use common::sync::Uid; use common::{comp::group, sync::Uid};
use conrod_core::{ use conrod_core::{
color, color,
widget::{self, Button, Image, Rectangle, Scrollbar, Text}, widget::{self, Button, Image, Rectangle, Scrollbar, Text},
@ -467,68 +467,90 @@ impl<'a> Widget for Social<'a> {
} }
// Invite Button // Invite Button
let selected_ingame = state let is_leader_or_not_in_group = self
.selected_uid .client
.as_ref() .group_info()
.map(|(s, _)| *s) .map_or(true, |(_, l_uid)| self.client.uid() == Some(l_uid));
.filter(|selected| {
self.client let current_members = self
.player_list .client
.get(selected) .group_members()
.map_or(false, |selected_player| { .iter()
selected_player.is_online && selected_player.character.is_some() .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(|| { .flatten();
self.selected_entity
.and_then(|s| self.client.state().read_component_copied(s.0)) let invite_button = Button::image(self.imgs.button)
});
// 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 {}",
&current_members,
&self.localized_strings.get("hud.group.members")
)
} else {
(&self.localized_strings.get("hud.group.members")).to_string()
};
if Button::image(self.imgs.button)
.w_h(106.0, 26.0) .w_h(106.0, 26.0)
.bottom_right_with_margins_on(state.ids.frame, 9.0, 7.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 self.imgs.button_hover
} else { } else {
self.imgs.button self.imgs.button
}) })
.press_image(if selected_ingame.is_some() { .press_image(if selected_to_invite.is_some() {
self.imgs.button_press self.imgs.button_press
} else { } else {
self.imgs.button 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_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 TEXT_COLOR
} else { } else {
TEXT_COLOR_3 TEXT_COLOR_3
}) })
.image_color(if selected_ingame.is_some() { .image_color(if selected_to_invite.is_some() {
TEXT_COLOR TEXT_COLOR
} else { } else {
TEXT_COLOR_3 TEXT_COLOR_3
}) })
.label_font_size(self.fonts.cyri.scale(15)) .label_font_size(self.fonts.cyri.scale(15))
.label_font_id(self.fonts.cyri.conrod_id) .label_font_id(self.fonts.cyri.conrod_id);
.with_tooltip(self.tooltip_manager, &tooltip_txt, "", &button_tooltip)
.set(state.ids.invite_button, ui) if if self.client.group_info().is_some() {
.was_clicked() 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)); events.push(Event::Invite(uid));
state.update(|s| { state.update(|s| {
s.selected_uid = None; s.selected_uid = None;