diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fdf61f498..52d7b29984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Command to toggle experimental shaders. - Faster Energy Regeneration while sitting. - Lantern glow for dropped lanterns. +- Suggests commands when an invalid one is entered in chat and added Client-side commands to /help. ### Changed - Bats move slower and use a simple proportional controller to maintain altitude diff --git a/Cargo.lock b/Cargo.lock index 6e181f4016..4cbf806288 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3216,6 +3216,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + [[package]] name = "lewton" version = "0.10.2" @@ -7080,6 +7086,7 @@ dependencies = [ "itertools", "keyboard-keynames", "lazy_static", + "levenshtein", "mimalloc", "mumble-link", "native-dialog", diff --git a/common/net/src/synced_components.rs b/common/net/src/synced_components.rs index 1dfdf57da7..ac28d26da2 100644 --- a/common/net/src/synced_components.rs +++ b/common/net/src/synced_components.rs @@ -28,6 +28,7 @@ macro_rules! synced_components { health: Health, poise: Poise, light_emitter: LightEmitter, + loot_owner: LootOwner, item: Item, scale: Scale, group: Group, @@ -56,10 +57,10 @@ macro_rules! synced_components { // Synced to the client only for its own entity + admin: Admin, combo: Combo, active_abilities: ActiveAbilities, can_build: CanBuild, - loot_owner: LootOwner, } }; } @@ -151,6 +152,10 @@ impl NetSync for LightEmitter { const SYNC_FROM: SyncFrom = SyncFrom::AnyEntity; } +impl NetSync for LootOwner { + const SYNC_FROM: SyncFrom = SyncFrom::AnyEntity; +} + impl NetSync for Item { const SYNC_FROM: SyncFrom = SyncFrom::AnyEntity; } @@ -217,6 +222,10 @@ impl NetSync for SkillSet { // These are synced only from the client's own entity. +impl NetSync for Admin { + const SYNC_FROM: SyncFrom = SyncFrom::ClientEntity; +} + impl NetSync for Combo { const SYNC_FROM: SyncFrom = SyncFrom::ClientEntity; } @@ -228,7 +237,3 @@ impl NetSync for ActiveAbilities { impl NetSync for CanBuild { const SYNC_FROM: SyncFrom = SyncFrom::ClientEntity; } - -impl NetSync for LootOwner { - const SYNC_FROM: SyncFrom = SyncFrom::AnyEntity; -} diff --git a/common/src/cmd.rs b/common/src/cmd.rs index 1f3d4b8852..08d1a6741a 100644 --- a/common/src/cmd.rs +++ b/common/src/cmd.rs @@ -242,18 +242,20 @@ pub enum ServerChatCommand { Adminify, Airship, Alias, - ApplyBuff, Ban, BattleMode, BattleModeForce, Body, + Buff, Build, BuildAreaAdd, BuildAreaList, BuildAreaRemove, Campfire, + CreateLocation, DebugColumn, DebugWays, + DeleteLocation, DisconnectAllPlayers, DropAll, Dummy, @@ -277,9 +279,12 @@ pub enum ServerChatCommand { Kit, Lantern, Light, + Lightning, + Location, MakeBlock, MakeNpc, MakeSprite, + MakeVolume, Motd, Object, PermitBuild, @@ -305,15 +310,10 @@ pub enum ServerChatCommand { Unban, Version, Waypoint, + WeatherZone, Whitelist, Wiring, World, - MakeVolume, - Location, - CreateLocation, - DeleteLocation, - WeatherZone, - Lightning, } impl ServerChatCommand { @@ -339,7 +339,7 @@ impl ServerChatCommand { "Change your alias", Some(Moderator), ), - ServerChatCommand::ApplyBuff => cmd( + ServerChatCommand::Buff => cmd( vec![ Enum("buff", BUFFS.clone(), Required), Float("strength", 0.01, Optional), @@ -734,11 +734,11 @@ impl ServerChatCommand { ServerChatCommand::Adminify => "adminify", ServerChatCommand::Airship => "airship", ServerChatCommand::Alias => "alias", - ServerChatCommand::ApplyBuff => "buff", ServerChatCommand::Ban => "ban", ServerChatCommand::BattleMode => "battlemode", ServerChatCommand::BattleModeForce => "battlemode_force", ServerChatCommand::Body => "body", + ServerChatCommand::Buff => "buff", ServerChatCommand::Build => "build", ServerChatCommand::BuildAreaAdd => "build_area_add", ServerChatCommand::BuildAreaList => "build_area_list", @@ -759,14 +759,14 @@ impl ServerChatCommand { ServerChatCommand::GroupPromote => "group_promote", ServerChatCommand::GroupLeave => "group_leave", ServerChatCommand::Health => "health", - ServerChatCommand::JoinFaction => "join_faction", ServerChatCommand::Help => "help", ServerChatCommand::Home => "home", + ServerChatCommand::JoinFaction => "join_faction", ServerChatCommand::Jump => "jump", ServerChatCommand::Kick => "kick", ServerChatCommand::Kill => "kill", - ServerChatCommand::Kit => "kit", ServerChatCommand::KillNpcs => "kill_npcs", + ServerChatCommand::Kit => "kit", ServerChatCommand::Lantern => "lantern", ServerChatCommand::Light => "light", ServerChatCommand::MakeBlock => "make_block", @@ -824,7 +824,9 @@ impl ServerChatCommand { } /// Produce an iterator over all the available commands - pub fn iter() -> impl Iterator { ::iter() } + pub fn iter() -> impl Iterator + Clone { + ::iter() + } /// A message that explains what the command does pub fn help_string(&self) -> String { @@ -1042,6 +1044,18 @@ mod tests { use super::*; use crate::comp::Item; + #[test] + fn verify_cmd_list_sorted() { + let mut list = ServerChatCommand::iter() + .map(|c| c.keyword()) + .collect::>(); + + // Vec::is_sorted is unstable, so we do it the hard way + let list2 = list.clone(); + list.sort_unstable(); + assert_eq!(list, list2); + } + #[test] fn test_loading_skill_presets() { SkillPresetManifest::load_expect(PRESET_MANIFEST_PATH); } diff --git a/common/src/comp/admin.rs b/common/src/comp/admin.rs index 137afce083..2254852f28 100644 --- a/common/src/comp/admin.rs +++ b/common/src/comp/admin.rs @@ -1,17 +1,18 @@ use clap::arg_enum; -use specs::Component; +use serde::{Deserialize, Serialize}; +use specs::{Component, DerefFlaggedStorage, VecStorage}; arg_enum! { - #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] + #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] pub enum AdminRole { Moderator = 0, Admin = 1, } } -#[derive(Clone, Copy)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Admin(pub AdminRole); impl Component for Admin { - type Storage = specs::VecStorage; + type Storage = DerefFlaggedStorage>; } diff --git a/common/src/event.rs b/common/src/event.rs index 14a3394f20..d1b52e5c50 100644 --- a/common/src/event.rs +++ b/common/src/event.rs @@ -18,6 +18,7 @@ use crate::{ use serde::{Deserialize, Serialize}; use specs::Entity as EcsEntity; use std::{collections::VecDeque, ops::DerefMut, sync::Mutex}; +use uuid::Uuid; use vek::*; pub type SiteId = u64; @@ -227,6 +228,11 @@ pub enum ServerEvent { entity: EcsEntity, update: comp::MapMarkerChange, }, + MakeAdmin { + entity: EcsEntity, + admin: comp::Admin, + uuid: Uuid, + }, } pub struct EventBus { diff --git a/common/state/src/state.rs b/common/state/src/state.rs index 8c15a4349b..96ac72f74e 100644 --- a/common/state/src/state.rs +++ b/common/state/src/state.rs @@ -203,6 +203,7 @@ impl State { ecs.register::(); ecs.register::(); ecs.register::(); + ecs.register::(); // Register components send from clients -> server ecs.register::(); @@ -236,7 +237,6 @@ impl State { ecs.register::(); ecs.register::(); ecs.register::(); - ecs.register::(); ecs.register::(); ecs.register::(); ecs.register::(); diff --git a/server/src/cmd.rs b/server/src/cmd.rs index 98a81f9e9a..337cd40a51 100644 --- a/server/src/cmd.rs +++ b/server/src/cmd.rs @@ -1,7 +1,6 @@ //! # Implementing new commands. //! To implement a new command provide a handler function //! in [do_command]. - use crate::{ client::Client, location::Locations, @@ -125,11 +124,11 @@ fn do_command( ServerChatCommand::Adminify => handle_adminify, ServerChatCommand::Airship => handle_spawn_airship, ServerChatCommand::Alias => handle_alias, - ServerChatCommand::ApplyBuff => handle_apply_buff, ServerChatCommand::Ban => handle_ban, ServerChatCommand::BattleMode => handle_battlemode, ServerChatCommand::BattleModeForce => handle_battlemode_force, ServerChatCommand::Body => handle_body, + ServerChatCommand::Buff => handle_buff, ServerChatCommand::Build => handle_build, ServerChatCommand::BuildAreaAdd => handle_build_area_add, ServerChatCommand::BuildAreaList => handle_build_area_list, @@ -3510,7 +3509,7 @@ fn handle_server_physics( } } -fn handle_apply_buff( +fn handle_buff( server: &mut Server, _client: EcsEntity, target: EcsEntity, diff --git a/server/src/events/entity_manipulation.rs b/server/src/events/entity_manipulation.rs index 2a0e54be99..eb13f2c4ec 100644 --- a/server/src/events/entity_manipulation.rs +++ b/server/src/events/entity_manipulation.rs @@ -11,6 +11,7 @@ use crate::{ sys::terrain::SAFE_ZONE_RADIUS, Server, SpawnPoint, StateExt, }; +use authc::Uuid; use common::{ combat, combat::DamageContributor, @@ -1458,3 +1459,16 @@ pub fn handle_update_map_marker( } } } + +pub fn handle_make_admin(server: &mut Server, entity: EcsEntity, admin: comp::Admin, uuid: Uuid) { + if server + .state + .read_storage::() + .get(entity) + .map_or(false, |player| player.uuid() == uuid) + { + server + .state + .write_component_ignore_entity_dead(entity, admin); + } +} diff --git a/server/src/events/mod.rs b/server/src/events/mod.rs index 80a9152db2..b43be72f3c 100644 --- a/server/src/events/mod.rs +++ b/server/src/events/mod.rs @@ -13,7 +13,8 @@ use entity_manipulation::{ handle_aura, handle_bonk, handle_buff, handle_change_ability, handle_combo_change, handle_delete, handle_destroy, handle_energy_change, handle_entity_attacked_hook, handle_explosion, handle_health_change, handle_knockback, handle_land_on_ground, - handle_parry_hook, handle_poise, handle_respawn, handle_teleport_to, handle_update_map_marker, + handle_make_admin, handle_parry_hook, handle_poise, handle_respawn, handle_teleport_to, + handle_update_map_marker, }; use group_manip::handle_group; use information::handle_site_info; @@ -288,6 +289,11 @@ impl Server { ServerEvent::UpdateMapMarker { entity, update } => { handle_update_map_marker(self, entity, update) }, + ServerEvent::MakeAdmin { + entity, + admin, + uuid, + } => handle_make_admin(self, entity, admin, uuid), } } diff --git a/server/src/sys/msg/register.rs b/server/src/sys/msg/register.rs index f15a7406aa..046eb85b85 100644 --- a/server/src/sys/msg/register.rs +++ b/server/src/sys/msg/register.rs @@ -61,10 +61,10 @@ pub struct ReadData<'a> { pub struct Sys; impl<'a> System<'a> for Sys { type SystemData = ( + Read<'a, EventBus>, ReadData<'a>, WriteStorage<'a, Client>, WriteStorage<'a, Player>, - WriteStorage<'a, Admin>, WriteStorage<'a, PendingLogin>, ); @@ -74,7 +74,7 @@ impl<'a> System<'a> for Sys { fn run( _job: &mut Job, - (read_data, mut clients, mut players, mut admins, mut pending_logins): Self::SystemData, + (event_bus, read_data, mut clients, mut players, mut pending_logins): Self::SystemData, ) { // Player list to send new players, and lookup from UUID to entity (so we don't // have to do a linear scan over all entities on each login to see if @@ -87,7 +87,7 @@ impl<'a> System<'a> for Sys { &read_data.uids, &players, read_data.stats.maybe(), - admins.maybe(), + read_data.trackers.admin.maybe(), ) .join() .map(|(entity, uid, player, stats, admin)| { @@ -379,6 +379,7 @@ impl<'a> System<'a> for Sys { .into_values() .map(|(entity, player, admin, msg)| { let username = &player.alias; + let uuid = player.uuid(); info!(?username, "New User"); // Add Player component to this client. // @@ -396,9 +397,13 @@ impl<'a> System<'a> for Sys { // Give the Admin component to the player if their name exists in // admin list if let Some(admin) = admin { - admins - .insert(entity, Admin(admin.role.into())) - .expect("Inserting into players proves the entity exists."); + // We need to defer writing to the Admin storage since it's borrowed immutably + // by this system via TrackedStorages. + event_bus.emit_now(ServerEvent::MakeAdmin { + entity, + admin: Admin(admin.role.into()), + uuid, + }); } msg }) diff --git a/voxygen/Cargo.toml b/voxygen/Cargo.toml index cf700b4a94..c630cfc619 100644 --- a/voxygen/Cargo.toml +++ b/voxygen/Cargo.toml @@ -87,6 +87,7 @@ specs = { version = "0.18", features = ["serde", "storage-event-control", "deriv # Mathematics vek = {version = "0.15.8", features = ["serde"]} +levenshtein = "1.0.5" # Controller gilrs = {version = "0.10.0", features = ["serde-serialize"]} diff --git a/voxygen/src/cmd.rs b/voxygen/src/cmd.rs index fd286d12a5..3800b79a7e 100644 --- a/voxygen/src/cmd.rs +++ b/voxygen/src/cmd.rs @@ -4,13 +4,15 @@ use crate::{ render::ExperimentalShader, session::settings_change::change_render_mode, GlobalState, }; use client::Client; -use common::{cmd::*, parse_cmd_args, uuid::Uuid}; +use common::{cmd::*, comp::Admin, parse_cmd_args, uuid::Uuid}; +use levenshtein::levenshtein; use strum::IntoEnumIterator; // Please keep this sorted alphabetically, same as with server commands :-) #[derive(Clone, Copy, strum::EnumIter)] pub enum ClientChatCommand { ExperimentalShader, + Help, Mute, Unmute, } @@ -21,16 +23,6 @@ impl ClientChatCommand { use Requirement::*; let cmd = ChatCommandData::new; match self { - ClientChatCommand::Mute => cmd( - vec![PlayerName(Required)], - "Mutes chat messages from a player.", - None, - ), - ClientChatCommand::Unmute => cmd( - vec![PlayerName(Required)], - "Unmutes a player muted with the 'mute' command.", - None, - ), ClientChatCommand::ExperimentalShader => cmd( vec![Enum( "Shader", @@ -42,14 +34,30 @@ impl ClientChatCommand { "Toggles an experimental shader.", None, ), + ClientChatCommand::Help => cmd( + vec![Command(Optional)], + "Display information about commands", + None, + ), + ClientChatCommand::Mute => cmd( + vec![PlayerName(Required)], + "Mutes chat messages from a player.", + None, + ), + ClientChatCommand::Unmute => cmd( + vec![PlayerName(Required)], + "Unmutes a player muted with the 'mute' command.", + None, + ), } } pub fn keyword(&self) -> &'static str { match self { + ClientChatCommand::ExperimentalShader => "experimental_shader", + ClientChatCommand::Help => "help", ClientChatCommand::Mute => "mute", ClientChatCommand::Unmute => "unmute", - ClientChatCommand::ExperimentalShader => "experimental_shader", } } @@ -85,7 +93,9 @@ impl ClientChatCommand { } /// Produce an iterator over all the available commands - pub fn iter() -> impl Iterator { ::iter() } + pub fn iter() -> impl Iterator + Clone { + ::iter() + } /// Produce an iterator that first goes over all the short keywords /// and their associated commands and then iterates over all the normal @@ -113,15 +123,15 @@ pub enum ChatCommandKind { } impl FromStr for ChatCommandKind { - type Err = String; + type Err = (); - fn from_str(s: &str) -> Result { + fn from_str(s: &str) -> Result { if let Ok(cmd) = s.parse::() { Ok(ChatCommandKind::Client(cmd)) } else if let Ok(cmd) = s.parse::() { Ok(ChatCommandKind::Server(cmd)) } else { - Err(format!("Could not find a command named {}.", s)) + Err(()) } } } @@ -142,19 +152,49 @@ pub fn run_command( cmd: &str, args: Vec, ) -> CommandResult { - let command = ChatCommandKind::from_str(cmd)?; + let command = ChatCommandKind::from_str(cmd); match command { - ChatCommandKind::Server(cmd) => { + Ok(ChatCommandKind::Server(cmd)) => { client.send_command(cmd.keyword().into(), args); Ok(None) // The server will provide a response when the command is run }, - ChatCommandKind::Client(cmd) => { + Ok(ChatCommandKind::Client(cmd)) => { Ok(Some(run_client_command(client, global_state, cmd, args)?)) }, + Err(()) => Err(invalid_command_message(client, cmd.to_string())), } } +fn invalid_command_message(client: &Client, user_entered_invalid_command: String) -> String { + let entity_role = client + .state() + .read_storage::() + .get(client.entity()) + .map(|admin| admin.0); + + let usable_commands = ServerChatCommand::iter() + .filter(|cmd| cmd.needs_role() <= entity_role) + .map(|cmd| cmd.keyword()) + .chain(ClientChatCommand::iter().map(|cmd| cmd.keyword())); + + let most_similar_str = usable_commands + .clone() + .min_by_key(|cmd| levenshtein(&user_entered_invalid_command, cmd)) + .expect("At least one command exists."); + + let commands_with_same_prefix = usable_commands + .filter(|cmd| cmd.starts_with(&user_entered_invalid_command) && cmd != &most_similar_str); + + format!( + "Could not find a command named {}. Did you mean any of the following? \n/{} {} \n\nType \ + /help to see a list of all commands.", + user_entered_invalid_command, + most_similar_str, + commands_with_same_prefix.fold(String::new(), |s, arg| s + "\n/" + arg) + ) +} + fn run_client_command( client: &mut Client, global_state: &mut GlobalState, @@ -162,14 +202,52 @@ fn run_client_command( args: Vec, ) -> Result { let command = match command { + ClientChatCommand::ExperimentalShader => handle_experimental_shader, + ClientChatCommand::Help => handle_help, ClientChatCommand::Mute => handle_mute, ClientChatCommand::Unmute => handle_unmute, - ClientChatCommand::ExperimentalShader => handle_experimental_shader, }; command(client, global_state, args) } +fn handle_help( + client: &Client, + _global_state: &mut GlobalState, + args: Vec, +) -> Result { + if let Some(cmd) = parse_cmd_args!(args, ServerChatCommand) { + Ok(cmd.help_string()) + } else { + let mut message = String::new(); + let entity_role = client + .state() + .read_storage::() + .get(client.entity()) + .map(|admin| admin.0); + + ClientChatCommand::iter().for_each(|cmd| { + message += &cmd.help_string(); + message += "\n"; + }); + // Iterate through all ServerChatCommands you have permission to use. + ServerChatCommand::iter() + .filter(|cmd| cmd.needs_role() <= entity_role) + .for_each(|cmd| { + message += &cmd.help_string(); + message += "\n"; + }); + message += "Additionally, you can use the following shortcuts:"; + ServerChatCommand::iter() + .filter(|cmd| cmd.needs_role() <= entity_role) + .filter_map(|cmd| cmd.short_keyword().map(|k| (k, cmd))) + .for_each(|(k, cmd)| { + message += &format!(" /{} => /{}", k, cmd.keyword()); + }); + Ok(message) + } +} + fn handle_mute( client: &Client, global_state: &mut GlobalState,