use std::str::FromStr; use crate::{ render::ExperimentalShader, session::{settings_change::change_render_mode, SessionState}, GlobalState, }; use client::Client; use common::{ cmd::*, comp::Admin, link::Is, mounting::{Mount, Rider, VolumeRider}, parse_cmd_args, resources::PlayerEntity, uid::Uid, uuid::Uuid, }; use common_net::sync::WorldSyncExt; use itertools::Itertools; use levenshtein::levenshtein; use specs::{Join, WorldExt}; use strum::{EnumIter, IntoEnumIterator}; // Please keep this sorted alphabetically, same as with server commands :-) #[derive(Clone, Copy, strum::EnumIter)] pub enum ClientChatCommand { ExperimentalShader, Help, Mute, Unmute, } impl ClientChatCommand { pub fn data(&self) -> ChatCommandData { use ArgumentSpec::*; use Requirement::*; let cmd = ChatCommandData::new; match self { ClientChatCommand::ExperimentalShader => cmd( vec![Enum( "Shader", ExperimentalShader::iter() .map(|item| item.to_string()) .collect(), Optional, )], "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", } } /// A message that explains what the command does pub fn help_string(&self) -> String { let data = self.data(); let usage = std::iter::once(format!("/{}", self.keyword())) .chain(data.args.iter().map(|arg| arg.usage_string())) .collect::>() .join(" "); format!("{}: {}", usage, data.description) } /// Returns a format string for parsing arguments with scan_fmt pub fn arg_fmt(&self) -> String { self.data() .args .iter() .map(|arg| match arg { ArgumentSpec::PlayerName(_) => "{}", ArgumentSpec::EntityTarget(_) => "{}", ArgumentSpec::SiteName(_) => "{/.*/}", ArgumentSpec::Float(_, _, _) => "{}", ArgumentSpec::Integer(_, _, _) => "{d}", ArgumentSpec::Any(_, _) => "{}", ArgumentSpec::Command(_) => "{}", ArgumentSpec::Message(_) => "{/.*/}", ArgumentSpec::SubCommand => "{} {/.*/}", ArgumentSpec::Enum(_, _, _) => "{}", ArgumentSpec::AssetPath(_, _, _, _) => "{}", ArgumentSpec::Boolean(_, _, _) => "{}", ArgumentSpec::Flag(_) => "{}", }) .collect::>() .join(" ") } /// Produce an iterator over all the available commands 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 /// keywords with their associated commands pub fn iter_with_keywords() -> impl Iterator { Self::iter().map(|c| (c.keyword(), c)) } } impl FromStr for ClientChatCommand { type Err = (); fn from_str(keyword: &str) -> Result { Self::iter() .map(|c| (c.keyword(), c)) .find_map(|(kwd, command)| (kwd == keyword).then_some(command)) .ok_or(()) } } #[derive(Clone, Copy)] pub enum ChatCommandKind { Client(ClientChatCommand), Server(ServerChatCommand), } impl FromStr for ChatCommandKind { type Err = (); 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(()) } } } /// Represents the feedback shown to the user of a command, if any. Server /// commands give their feedback as an event, so in those cases this will always /// be Ok(None). An Err variant will be be displayed with the error icon and /// text color type CommandResult = Result, String>; #[derive(EnumIter)] enum ClientEntityTarget { Target, Selected, Viewpoint, Mount, Rider, TargetSelf, } impl ClientEntityTarget { const PREFIX: char = '@'; fn keyword(&self) -> &'static str { match self { ClientEntityTarget::Target => "target", ClientEntityTarget::Selected => "selected", ClientEntityTarget::Viewpoint => "viewpoint", ClientEntityTarget::Mount => "mount", ClientEntityTarget::Rider => "rider", ClientEntityTarget::TargetSelf => "self", } } } fn preproccess_command( session_state: &mut SessionState, command: &ChatCommandKind, args: &mut [String], ) -> CommandResult { let mut cmd_args = match command { ChatCommandKind::Client(cmd) => cmd.data().args, ChatCommandKind::Server(cmd) => cmd.data().args, }; let client = &mut session_state.client.borrow_mut(); let ecs = client.state().ecs(); let player = ecs.read_resource::().0; let mut command_start = 0; for (i, arg) in args.iter_mut().enumerate() { let mut could_be_entity_target = false; if let Some(post_cmd_args) = cmd_args.get(i - command_start..) { for (j, arg_spec) in post_cmd_args.iter().enumerate() { match arg_spec { ArgumentSpec::EntityTarget(_) => could_be_entity_target = true, ArgumentSpec::SubCommand => { if let Some(sub_command) = ServerChatCommand::iter().find(|cmd| cmd.keyword() == arg) { cmd_args = sub_command.data().args; command_start = i + j + 1; break; } }, ArgumentSpec::AssetPath(_, prefix, _, _) => { *arg = prefix.to_string() + arg; }, _ => {}, } if matches!(arg_spec.requirement(), Requirement::Required) { break; } } } else if matches!(cmd_args.last(), Some(ArgumentSpec::SubCommand)) { could_be_entity_target = true; } if could_be_entity_target && arg.starts_with(ClientEntityTarget::PREFIX) { let target_str = arg.trim_start_matches(ClientEntityTarget::PREFIX); let target = ClientEntityTarget::iter() .find(|t| t.keyword() == target_str) .ok_or_else(|| { let help_string = ClientEntityTarget::iter() .map(|t| t.keyword().to_string()) .reduce(|a, b| format!("{a}/{b}")) .unwrap_or_default(); format!("Expected {help_string} after '@' found {target_str}") })?; let uid = match target { ClientEntityTarget::Target => session_state .target_entity .and_then(|e| ecs.uid_from_entity(e)) .ok_or("Not looking at a valid target".to_string())?, ClientEntityTarget::Selected => session_state .selected_entity .and_then(|(e, _)| ecs.uid_from_entity(e)) .ok_or("You don't have a valid target selected".to_string())?, ClientEntityTarget::Viewpoint => session_state .viewpoint_entity .and_then(|e| ecs.uid_from_entity(e)) .ok_or("Not viewing from a valid viewpoint entity".to_string())?, ClientEntityTarget::Mount => { if let Some(player) = player { ecs.read_storage::>() .get(player) .map(|is_rider| is_rider.mount) .or(ecs.read_storage::>().get(player).and_then( |is_rider| match is_rider.pos.kind { common::mounting::Volume::Terrain => None, common::mounting::Volume::Entity(uid) => Some(uid), }, )) .ok_or("Not riding a valid entity".to_string())? } else { return Err("No player entity".to_string()); } }, ClientEntityTarget::Rider => { if let Some(player) = player { ecs.read_storage::>() .get(player) .map(|is_mount| is_mount.rider) .ok_or("No valid rider".to_string())? } else { return Err("No player entity".to_string()); } }, ClientEntityTarget::TargetSelf => player .and_then(|e| ecs.uid_from_entity(e)) .ok_or("No player entity")?, }; let uid = u64::from(uid); *arg = format!("uid@{uid}"); } } Ok(None) } /// Runs a command by either sending it to the server or processing it /// locally. Returns a String to be output to the chat. // Note: it's not clear what data future commands will need access to, so the // signature of this function might change pub fn run_command( session_state: &mut SessionState, global_state: &mut GlobalState, cmd: &str, mut args: Vec, ) -> CommandResult { let command = ChatCommandKind::from_str(cmd) .map_err(|_| invalid_command_message(&session_state.client.borrow(), cmd.to_string()))?; preproccess_command(session_state, &command, &mut args)?; let client = &mut session_state.client.borrow_mut(); match command { 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(Some(run_client_command(client, global_state, cmd, args)?)) }, } } 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, command: ClientChatCommand, args: Vec, ) -> Result { let command = match command { ClientChatCommand::ExperimentalShader => handle_experimental_shader, ClientChatCommand::Help => handle_help, ClientChatCommand::Mute => handle_mute, ClientChatCommand::Unmute => handle_unmute, }; 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, args: Vec, ) -> Result { if let Some(alias) = parse_cmd_args!(args, String) { let target = client .player_list() .values() .find(|p| p.player_alias == alias) .ok_or_else(|| format!("Could not find a player named {}", alias))?; if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid)) { if target.uuid == me.uuid { return Err("You cannot mute yourself.".to_string()); } } if global_state .profile .mutelist .insert(target.uuid, alias.clone()) .is_none() { Ok(format!("Successfully muted player {}.", alias)) } else { Err(format!("{} is already muted.", alias)) } } else { Err("You must specify a player to mute.".to_string()) } } fn handle_unmute( client: &Client, global_state: &mut GlobalState, args: Vec, ) -> Result { // Note that we don't care if this is a real player, so that it's possible // to unmute someone when they're offline if let Some(alias) = parse_cmd_args!(args, String) { if let Some(uuid) = global_state .profile .mutelist .iter() .find(|(_, v)| **v == alias) .map(|(k, _)| *k) { if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid)) { if uuid == me.uuid { return Err("You cannot unmute yourself.".to_string()); } } global_state.profile.mutelist.remove(&uuid); Ok(format!("Successfully unmuted player {}.", alias)) } else { Err(format!("Could not find a muted player named {}.", alias)) } } else { Err("You must specify a player to unmute.".to_string()) } } fn handle_experimental_shader( _client: &Client, global_state: &mut GlobalState, args: Vec, ) -> Result { if args.is_empty() { ExperimentalShader::iter() .map(|s| { let is_active = global_state .settings .graphics .render_mode .experimental_shaders .contains(&s); format!("[{}] {}", if is_active { "x" } else { " " }, s) }) .reduce(|mut a, b| { a.push('\n'); a.push_str(&b); a }) .ok_or("There are no experimental shaders.".to_string()) } else if let Some(item) = parse_cmd_args!(args, String) { if let Ok(shader) = ExperimentalShader::from_str(&item) { let mut new_render_mode = global_state.settings.graphics.render_mode.clone(); let res = if new_render_mode.experimental_shaders.remove(&shader) { Ok(format!("Disabled {item}.")) } else { new_render_mode.experimental_shaders.insert(shader); Ok(format!("Enabled {item}.")) }; change_render_mode( new_render_mode, &mut global_state.window, &mut global_state.settings, ); res } else { Err(format!( "{item} is not an expermimental shader, use this command with any arguments to \ see a complete list." )) } } else { Err( "You must specify a valid experimental shader, to get a list of experimental shaders, \ use this command without any arguments." .to_string(), ) } } /// A helper function to get the Uuid of a player with a given alias pub fn get_player_uuid(client: &Client, alias: &String) -> Option { client .player_list() .values() .find(|p| p.player_alias == *alias) .map(|p| p.uuid) } trait TabComplete { fn complete(&self, part: &str, client: &Client) -> Vec; } impl TabComplete for ArgumentSpec { fn complete(&self, part: &str, client: &Client) -> Vec { match self { ArgumentSpec::PlayerName(_) => complete_player(part, client), ArgumentSpec::EntityTarget(_) => { if let Some((spec, end)) = part.split_once(ClientEntityTarget::PREFIX) { match spec { "" => ClientEntityTarget::iter() .filter_map(|target| { let ident = target.keyword(); if ident.starts_with(end) { Some(format!("@{ident}")) } else { None } }) .collect(), "uid" => { if let Some(end) = u64::from_str(end).ok().or(end.is_empty().then_some(0)) { client .state() .ecs() .read_storage::() .join() .filter_map(|uid| { let uid = u64::from(*uid); if end < uid { Some(format!("uid@{uid}")) } else { None } }) .collect() } else { vec![] } }, _ => vec![], } } else { complete_player(part, client) } }, ArgumentSpec::SiteName(_) => complete_site(part, client), ArgumentSpec::Float(_, x, _) => { if part.is_empty() { vec![format!("{:.1}", x)] } else { vec![] } }, ArgumentSpec::Integer(_, x, _) => { if part.is_empty() { vec![format!("{}", x)] } else { vec![] } }, ArgumentSpec::Any(_, _) => vec![], ArgumentSpec::Command(_) => complete_command(part, ' '), ArgumentSpec::Message(_) => complete_player(part, client), ArgumentSpec::SubCommand => complete_command(part, ' '), ArgumentSpec::Enum(_, strings, _) => strings .iter() .filter(|string| string.starts_with(part)) .map(|c| c.to_string()) .collect(), ArgumentSpec::AssetPath(_, prefix, paths, _) => { let part_with_prefix = prefix.to_string() + part; let depth = part_with_prefix.split('.').count(); paths .iter() .map(|path| path.as_str().split('.').take(depth).join(".")) .dedup() .filter(|string| string.starts_with(&part_with_prefix)) .filter_map(|c| Some(c.strip_prefix(prefix)?.to_string())) .collect() }, ArgumentSpec::Boolean(_, part, _) => ["true", "false"] .iter() .filter(|string| string.starts_with(part)) .map(|c| c.to_string()) .collect(), ArgumentSpec::Flag(part) => vec![part.to_string()], } } } fn complete_player(part: &str, client: &Client) -> Vec { client .player_list() .values() .map(|player_info| &player_info.player_alias) .filter(|alias| alias.starts_with(part)) .cloned() .collect() } fn complete_site(mut part: &str, client: &Client) -> Vec { if let Some(p) = part.strip_prefix('"') { part = p; } client .sites() .values() .filter_map(|site| match site.site.kind { common_net::msg::world_msg::SiteKind::Cave => None, _ => site.site.name.as_ref(), }) .filter(|name| name.starts_with(part)) .map(|name| { if name.contains(' ') { format!("\"{}\"", name) } else { name.clone() } }) .collect() } // Get the byte index of the nth word. Used in completing "/sudo p /subcmd" fn nth_word(line: &str, n: usize) -> Option { let mut is_space = false; let mut j = 0; for (i, c) in line.char_indices() { match (is_space, c.is_whitespace()) { (true, true) => {}, (true, false) => { is_space = false; j += 1; }, (false, true) => { is_space = true; }, (false, false) => {}, } if j == n { return Some(i); } } None } fn complete_command(part: &str, prefix: char) -> Vec { ServerChatCommand::iter_with_keywords() .map(|(kwd, _)| kwd) .chain(ClientChatCommand::iter_with_keywords().map(|(kwd, _)| kwd)) .filter(|kwd| kwd.starts_with(part)) .map(|kwd| format!("{}{}", prefix, kwd)) .collect() } pub fn complete(line: &str, client: &Client, cmd_prefix: char) -> Vec { let word = if line.chars().last().map_or(true, char::is_whitespace) { "" } else { line.split_whitespace().last().unwrap_or("") }; if line.starts_with(cmd_prefix) { let line = line.strip_prefix(cmd_prefix).unwrap_or(line); let mut iter = line.split_whitespace(); let cmd = iter.next().unwrap_or(""); let i = iter.count() + usize::from(word.is_empty()); if i == 0 { // Completing chat command name. This is the start of the line so the prefix // will be part of it let word = word.strip_prefix(cmd_prefix).unwrap_or(word); return complete_command(word, cmd_prefix); } let args = { if let Ok(cmd) = cmd.parse::() { Some(cmd.data().args) } else if let Ok(cmd) = cmd.parse::() { Some(cmd.data().args) } else { None } }; if let Some(args) = args { if let Some(arg) = args.get(i - 1) { // Complete ith argument arg.complete(word, client) } else { // Complete past the last argument match args.last() { Some(ArgumentSpec::SubCommand) => { if let Some(index) = nth_word(line, args.len()) { complete(&line[index..], client, cmd_prefix) } else { vec![] } }, Some(ArgumentSpec::Message(_)) => complete_player(word, client), _ => vec![], // End of command. Nothing to complete } } } else { // Completing for unknown chat command complete_player(word, client) } } else { // Not completing a command complete_player(word, client) } } #[test] fn verify_cmd_list_sorted() { let mut list = ClientChatCommand::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_complete_command() { assert_eq!(complete_command("mu", '/'), vec!["/mute".to_string()]); assert_eq!(complete_command("unba", '/'), vec!["/unban".to_string()]); assert_eq!(complete_command("make_", '/'), vec![ "/make_block".to_string(), "/make_npc".to_string(), "/make_sprite".to_string(), "/make_volume".to_string() ]); }