From 57ab1c5767831f7d50014c26605ae743a30375fa Mon Sep 17 00:00:00 2001
From: Samantha W <maveth1@pm.me>
Date: Tue, 14 Jun 2022 20:35:01 +0000
Subject: [PATCH] Add a client-side mutelist

---
 CHANGELOG.md                   |   2 +-
 client/src/cmd.rs              | 149 ---------
 client/src/lib.rs              |   1 -
 common/net/src/msg/server.rs   |   2 +
 common/src/bin/cmd_doc_gen.rs  |   4 +-
 common/src/cmd.rs              | 534 ++++++++++++++++++---------------
 server/src/cmd.rs              | 430 ++++++++++++--------------
 server/src/events/player.rs    |   1 +
 server/src/lib.rs              |   4 +-
 server/src/sys/msg/register.rs |   2 +
 voxygen/egui/src/admin.rs      |   6 +-
 voxygen/egui/src/lib.rs        |  22 +-
 voxygen/src/cmd.rs             | 411 +++++++++++++++++++++++++
 voxygen/src/hud/chat.rs        |  12 +-
 voxygen/src/hud/mod.rs         |  16 +
 voxygen/src/lib.rs             |   1 +
 voxygen/src/profile.rs         |   3 +-
 voxygen/src/session/mod.rs     |  12 +-
 voxygen/src/settings/chat.rs   |   2 +
 19 files changed, 965 insertions(+), 649 deletions(-)
 delete mode 100644 client/src/cmd.rs
 create mode 100644 voxygen/src/cmd.rs

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a48e155e3..28571cf25a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## [Unreleased]
 
 ### Added
-
+- Chat commands to mute and unmute players
 - Waypoints saved between sessions and shared with group members.
 - New rocks
 - Weapon trails
diff --git a/client/src/cmd.rs b/client/src/cmd.rs
deleted file mode 100644
index dd5dd8ba06..0000000000
--- a/client/src/cmd.rs
+++ /dev/null
@@ -1,149 +0,0 @@
-use crate::Client;
-use common::cmd::*;
-
-trait TabComplete {
-    fn complete(&self, part: &str, client: &Client) -> Vec<String>;
-}
-
-impl TabComplete for ArgumentSpec {
-    fn complete(&self, part: &str, client: &Client) -> Vec<String> {
-        match self {
-            ArgumentSpec::PlayerName(_) => 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::Boolean(_, part, _) => vec!["true", "false"]
-                .iter()
-                .filter(|string| string.starts_with(part))
-                .map(|c| c.to_string())
-                .collect(),
-        }
-    }
-}
-
-fn complete_player(part: &str, client: &Client) -> Vec<String> {
-    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<String> {
-    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()
-}
-
-fn complete_command(part: &str) -> Vec<String> {
-    let part = part.strip_prefix('/').unwrap_or(part);
-
-    ChatCommand::iter_with_keywords()
-        .map(|(kwd, _)| kwd)
-        .filter(|kwd| kwd.starts_with(part))
-        .map(|kwd| format!("/{}", kwd))
-        .collect()
-}
-
-// Get the byte index of the nth word. Used in completing "/sudo p /subcmd"
-fn nth_word(line: &str, n: usize) -> Option<usize> {
-    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
-}
-
-pub fn complete(line: &str, client: &Client) -> Vec<String> {
-    let word = if line.chars().last().map_or(true, char::is_whitespace) {
-        ""
-    } else {
-        line.split_whitespace().last().unwrap_or("")
-    };
-    if line.starts_with('/') {
-        let mut iter = line.split_whitespace();
-        let cmd = iter.next().unwrap();
-        let i = iter.count() + if word.is_empty() { 1 } else { 0 };
-        if i == 0 {
-            // Completing chat command name
-            complete_command(word)
-        } else if let Ok(cmd) = cmd.parse::<ChatCommand>() {
-            if let Some(arg) = cmd.data().args.get(i - 1) {
-                // Complete ith argument
-                arg.complete(word, client)
-            } else {
-                // Complete past the last argument
-                match cmd.data().args.last() {
-                    Some(ArgumentSpec::SubCommand) => {
-                        if let Some(index) = nth_word(line, cmd.data().args.len()) {
-                            complete(&line[index..], client)
-                        } 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)
-    }
-}
diff --git a/client/src/lib.rs b/client/src/lib.rs
index fe02b0031c..13c191ecdd 100644
--- a/client/src/lib.rs
+++ b/client/src/lib.rs
@@ -3,7 +3,6 @@
 #![feature(label_break_value, option_zip)]
 
 pub mod addr;
-pub mod cmd;
 pub mod error;
 
 // Reexports
diff --git a/common/net/src/msg/server.rs b/common/net/src/msg/server.rs
index 2223811301..4b233de6bc 100644
--- a/common/net/src/msg/server.rs
+++ b/common/net/src/msg/server.rs
@@ -14,6 +14,7 @@ use common::{
     terrain::{Block, TerrainChunk, TerrainChunkMeta, TerrainChunkSize},
     trade::{PendingTrade, SitePrices, TradeId, TradeResult},
     uid::Uid,
+    uuid::Uuid,
 };
 use hashbrown::HashMap;
 use serde::{Deserialize, Serialize};
@@ -229,6 +230,7 @@ pub struct PlayerInfo {
     pub is_online: bool,
     pub player_alias: String,
     pub character: Option<CharacterInfo>,
+    pub uuid: Uuid,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
diff --git a/common/src/bin/cmd_doc_gen.rs b/common/src/bin/cmd_doc_gen.rs
index 24ecb39fc5..d85c43a875 100644
--- a/common/src/bin/cmd_doc_gen.rs
+++ b/common/src/bin/cmd_doc_gen.rs
@@ -1,11 +1,11 @@
-use veloren_common::cmd::ChatCommand;
+use veloren_common::cmd::ServerChatCommand;
 
 /// This binary generates the markdown table used for the `players/commands.md`
 /// page in the Veloren Book. It can be run with `cargo cmd-doc-gen`.
 fn main() {
     println!("|Command|Description|Requires|Arguments|");
     println!("|-|-|-|-|");
-    for cmd in ChatCommand::iter() {
+    for cmd in ServerChatCommand::iter() {
         let args = cmd
             .data()
             .args
diff --git a/common/src/cmd.rs b/common/src/cmd.rs
index 4726980e78..479eae7585 100644
--- a/common/src/cmd.rs
+++ b/common/src/cmd.rs
@@ -39,82 +39,6 @@ impl ChatCommandData {
     }
 }
 
-// Please keep this sorted alphabetically :-)
-#[derive(Copy, Clone, strum::EnumIter)]
-pub enum ChatCommand {
-    Adminify,
-    Airship,
-    Alias,
-    ApplyBuff,
-    Ban,
-    BattleMode,
-    BattleModeForce,
-    Build,
-    BuildAreaAdd,
-    BuildAreaList,
-    BuildAreaRemove,
-    Campfire,
-    DebugColumn,
-    DisconnectAllPlayers,
-    DropAll,
-    Dummy,
-    Explosion,
-    Faction,
-    GiveItem,
-    Goto,
-    Group,
-    GroupInvite,
-    GroupKick,
-    GroupLeave,
-    GroupPromote,
-    Health,
-    Help,
-    Home,
-    JoinFaction,
-    Jump,
-    Kick,
-    Kill,
-    KillNpcs,
-    Kit,
-    Lantern,
-    Light,
-    MakeBlock,
-    MakeNpc,
-    MakeSprite,
-    Motd,
-    Object,
-    PermitBuild,
-    Players,
-    Region,
-    ReloadChunks,
-    RemoveLights,
-    RevokeBuild,
-    RevokeBuildAll,
-    Safezone,
-    Say,
-    ServerPhysics,
-    SetMotd,
-    Ship,
-    Site,
-    SkillPoint,
-    SkillPreset,
-    Spawn,
-    Sudo,
-    Tell,
-    Time,
-    Tp,
-    Unban,
-    Version,
-    Waypoint,
-    Whitelist,
-    Wiring,
-    World,
-    MakeVolume,
-    Location,
-    CreateLocation,
-    DeleteLocation,
-}
-
 #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
 pub enum KitSpec {
     Item(String),
@@ -299,30 +223,106 @@ lazy_static! {
     };
 }
 
-impl ChatCommand {
+// Please keep this sorted alphabetically :-)
+#[derive(Copy, Clone, strum::EnumIter)]
+pub enum ServerChatCommand {
+    Adminify,
+    Airship,
+    Alias,
+    ApplyBuff,
+    Ban,
+    BattleMode,
+    BattleModeForce,
+    Build,
+    BuildAreaAdd,
+    BuildAreaList,
+    BuildAreaRemove,
+    Campfire,
+    DebugColumn,
+    DisconnectAllPlayers,
+    DropAll,
+    Dummy,
+    Explosion,
+    Faction,
+    GiveItem,
+    Goto,
+    Group,
+    GroupInvite,
+    GroupKick,
+    GroupLeave,
+    GroupPromote,
+    Health,
+    Help,
+    Home,
+    JoinFaction,
+    Jump,
+    Kick,
+    Kill,
+    KillNpcs,
+    Kit,
+    Lantern,
+    Light,
+    MakeBlock,
+    MakeNpc,
+    MakeSprite,
+    Motd,
+    Object,
+    PermitBuild,
+    Players,
+    Region,
+    ReloadChunks,
+    RemoveLights,
+    RevokeBuild,
+    RevokeBuildAll,
+    Safezone,
+    Say,
+    ServerPhysics,
+    SetMotd,
+    Ship,
+    Site,
+    SkillPoint,
+    SkillPreset,
+    Spawn,
+    Sudo,
+    Tell,
+    Time,
+    Tp,
+    Unban,
+    Version,
+    Waypoint,
+    Whitelist,
+    Wiring,
+    World,
+    MakeVolume,
+    Location,
+    CreateLocation,
+    DeleteLocation,
+}
+
+impl ServerChatCommand {
     pub fn data(&self) -> ChatCommandData {
         use ArgumentSpec::*;
         use Requirement::*;
         use Role::*;
         let cmd = ChatCommandData::new;
         match self {
-            ChatCommand::Adminify => cmd(
+            ServerChatCommand::Adminify => cmd(
                 vec![PlayerName(Required), Enum("role", ROLES.clone(), Optional)],
                 "Temporarily gives a player a restricted admin role or removes the current one \
                  (if not given)",
                 Some(Admin),
             ),
-            ChatCommand::Airship => cmd(
+            ServerChatCommand::Airship => cmd(
                 vec![Float("destination_degrees_ccw_of_east", 90.0, Optional)],
                 "Spawns an airship",
                 Some(Admin),
             ),
-            ChatCommand::Alias => cmd(
+            ServerChatCommand::Alias => cmd(
                 vec![Any("name", Required)],
                 "Change your alias",
                 Some(Moderator),
             ),
-            ChatCommand::ApplyBuff => cmd(
+            ServerChatCommand::ApplyBuff => cmd(
                 vec![
                     Enum("buff", BUFFS.clone(), Required),
                     Float("strength", 0.01, Optional),
@@ -331,7 +331,7 @@ impl ChatCommand {
                 "Cast a buff on player",
                 Some(Admin),
             ),
-            ChatCommand::Ban => cmd(
+            ServerChatCommand::Ban => cmd(
                 vec![
                     PlayerName(Required),
                     Boolean("overwrite", "true".to_string(), Optional),
@@ -343,7 +343,7 @@ impl ChatCommand {
                 Some(Moderator),
             ),
             #[rustfmt::skip]
-            ChatCommand::BattleMode => cmd(
+            ServerChatCommand::BattleMode => cmd(
                 vec![Enum(
                     "battle mode",
                     vec!["pvp".to_owned(), "pve".to_owned()],
@@ -354,8 +354,9 @@ impl ChatCommand {
                 * pve (player vs environment).\n\
                 If called without arguments will show current battle mode.",
                 None,
+
             ),
-            ChatCommand::BattleModeForce => cmd(
+            ServerChatCommand::BattleModeForce => cmd(
                 vec![Enum(
                     "battle mode",
                     vec!["pvp".to_owned(), "pve".to_owned()],
@@ -364,8 +365,8 @@ impl ChatCommand {
                 "Change your battle mode flag without any checks",
                 Some(Admin),
             ),
-            ChatCommand::Build => cmd(vec![], "Toggles build mode on and off", None),
-            ChatCommand::BuildAreaAdd => cmd(
+            ServerChatCommand::Build => cmd(vec![], "Toggles build mode on and off", None),
+            ServerChatCommand::BuildAreaAdd => cmd(
                 vec![
                     Any("name", Required),
                     Integer("xlo", 0, Required),
@@ -378,40 +379,40 @@ impl ChatCommand {
                 "Adds a new build area",
                 Some(Admin),
             ),
-            ChatCommand::BuildAreaList => cmd(vec![], "List all build areas", Some(Admin)),
-            ChatCommand::BuildAreaRemove => cmd(
+            ServerChatCommand::BuildAreaList => cmd(vec![], "List all build areas", Some(Admin)),
+            ServerChatCommand::BuildAreaRemove => cmd(
                 vec![Any("name", Required)],
                 "Removes specified build area",
                 Some(Admin),
             ),
-            ChatCommand::Campfire => cmd(vec![], "Spawns a campfire", Some(Admin)),
-            ChatCommand::DebugColumn => cmd(
+            ServerChatCommand::Campfire => cmd(vec![], "Spawns a campfire", Some(Admin)),
+            ServerChatCommand::DebugColumn => cmd(
                 vec![Integer("x", 15000, Required), Integer("y", 15000, Required)],
                 "Prints some debug information about a column",
                 Some(Moderator),
             ),
-            ChatCommand::DisconnectAllPlayers => cmd(
+            ServerChatCommand::DisconnectAllPlayers => cmd(
                 vec![Any("confirm", Required)],
                 "Disconnects all players from the server",
                 Some(Admin),
             ),
-            ChatCommand::DropAll => cmd(
+            ServerChatCommand::DropAll => cmd(
                 vec![],
                 "Drops all your items on the ground",
                 Some(Moderator),
             ),
-            ChatCommand::Dummy => cmd(vec![], "Spawns a training dummy", Some(Admin)),
-            ChatCommand::Explosion => cmd(
+            ServerChatCommand::Dummy => cmd(vec![], "Spawns a training dummy", Some(Admin)),
+            ServerChatCommand::Explosion => cmd(
                 vec![Float("radius", 5.0, Required)],
                 "Explodes the ground around you",
                 Some(Admin),
             ),
-            ChatCommand::Faction => cmd(
+            ServerChatCommand::Faction => cmd(
                 vec![Message(Optional)],
                 "Send messages to your faction",
                 None,
             ),
-            ChatCommand::GiveItem => cmd(
+            ServerChatCommand::GiveItem => cmd(
                 vec![
                     Enum("item", ITEM_SPECS.clone(), Required),
                     Integer("num", 1, Optional),
@@ -419,7 +420,7 @@ impl ChatCommand {
                 "Give yourself some items.\nFor an example or to auto complete use Tab.",
                 Some(Admin),
             ),
-            ChatCommand::Goto => cmd(
+            ServerChatCommand::Goto => cmd(
                 vec![
                     Float("x", 0.0, Required),
                     Float("y", 0.0, Required),
@@ -428,40 +429,42 @@ impl ChatCommand {
                 "Teleport to a position",
                 Some(Admin),
             ),
-            ChatCommand::Group => cmd(vec![Message(Optional)], "Send messages to your group", None),
-            ChatCommand::GroupInvite => cmd(
+            ServerChatCommand::Group => {
+                cmd(vec![Message(Optional)], "Send messages to your group", None)
+            },
+            ServerChatCommand::GroupInvite => cmd(
                 vec![PlayerName(Required)],
                 "Invite a player to join a group",
                 None,
             ),
-            ChatCommand::GroupKick => cmd(
+            ServerChatCommand::GroupKick => cmd(
                 vec![PlayerName(Required)],
                 "Remove a player from a group",
                 None,
             ),
-            ChatCommand::GroupLeave => cmd(vec![], "Leave the current group", None),
-            ChatCommand::GroupPromote => cmd(
+            ServerChatCommand::GroupLeave => cmd(vec![], "Leave the current group", None),
+            ServerChatCommand::GroupPromote => cmd(
                 vec![PlayerName(Required)],
                 "Promote a player to group leader",
                 None,
             ),
-            ChatCommand::Health => cmd(
+            ServerChatCommand::Health => cmd(
                 vec![Integer("hp", 100, Required)],
                 "Set your current health",
                 Some(Admin),
             ),
-            ChatCommand::Help => ChatCommandData::new(
+            ServerChatCommand::Help => ChatCommandData::new(
                 vec![Command(Optional)],
                 "Display information about commands",
                 None,
             ),
-            ChatCommand::Home => cmd(vec![], "Return to the home town", Some(Moderator)),
-            ChatCommand::JoinFaction => ChatCommandData::new(
+            ServerChatCommand::Home => cmd(vec![], "Return to the home town", Some(Moderator)),
+            ServerChatCommand::JoinFaction => ChatCommandData::new(
                 vec![Any("faction", Optional)],
                 "Join/leave the specified faction",
                 None,
             ),
-            ChatCommand::Jump => cmd(
+            ServerChatCommand::Jump => cmd(
                 vec![
                     Float("x", 0.0, Required),
                     Float("y", 0.0, Required),
@@ -470,19 +473,19 @@ impl ChatCommand {
                 "Offset your current position",
                 Some(Admin),
             ),
-            ChatCommand::Kick => cmd(
+            ServerChatCommand::Kick => cmd(
                 vec![PlayerName(Required), Message(Optional)],
                 "Kick a player with a given username",
                 Some(Moderator),
             ),
-            ChatCommand::Kill => cmd(vec![], "Kill yourself", None),
-            ChatCommand::KillNpcs => cmd(vec![], "Kill the NPCs", Some(Admin)),
-            ChatCommand::Kit => cmd(
+            ServerChatCommand::Kill => cmd(vec![], "Kill yourself", None),
+            ServerChatCommand::KillNpcs => cmd(vec![], "Kill the NPCs", Some(Admin)),
+            ServerChatCommand::Kit => cmd(
                 vec![Enum("kit_name", KITS.to_vec(), Required)],
                 "Place a set of items into your inventory.",
                 Some(Admin),
             ),
-            ChatCommand::Lantern => cmd(
+            ServerChatCommand::Lantern => cmd(
                 vec![
                     Float("strength", 5.0, Required),
                     Float("r", 1.0, Optional),
@@ -492,7 +495,7 @@ impl ChatCommand {
                 "Change your lantern's strength and color",
                 Some(Admin),
             ),
-            ChatCommand::Light => cmd(
+            ServerChatCommand::Light => cmd(
                 vec![
                     Float("r", 1.0, Optional),
                     Float("g", 1.0, Optional),
@@ -505,7 +508,7 @@ impl ChatCommand {
                 "Spawn entity with light",
                 Some(Admin),
             ),
-            ChatCommand::MakeBlock => cmd(
+            ServerChatCommand::MakeBlock => cmd(
                 vec![
                     Enum("block", BLOCK_KINDS.clone(), Required),
                     Integer("r", 255, Optional),
@@ -515,7 +518,7 @@ impl ChatCommand {
                 "Make a block at your location with a color",
                 Some(Admin),
             ),
-            ChatCommand::MakeNpc => cmd(
+            ServerChatCommand::MakeNpc => cmd(
                 vec![
                     Enum("entity_config", ENTITY_CONFIGS.clone(), Required),
                     Integer("num", 1, Optional),
@@ -523,59 +526,61 @@ impl ChatCommand {
                 "Spawn entity from config near you.\nFor an example or to auto complete use Tab.",
                 Some(Admin),
             ),
-            ChatCommand::MakeSprite => cmd(
+            ServerChatCommand::MakeSprite => cmd(
                 vec![Enum("sprite", SPRITE_KINDS.clone(), Required)],
                 "Make a sprite at your location",
                 Some(Admin),
             ),
-            ChatCommand::Motd => cmd(vec![Message(Optional)], "View the server description", None),
-            ChatCommand::Object => cmd(
+            ServerChatCommand::Motd => {
+                cmd(vec![Message(Optional)], "View the server description", None)
+            },
+            ServerChatCommand::Object => cmd(
                 vec![Enum("object", OBJECTS.clone(), Required)],
                 "Spawn an object",
                 Some(Admin),
             ),
-            ChatCommand::PermitBuild => cmd(
+            ServerChatCommand::PermitBuild => cmd(
                 vec![Any("area_name", Required)],
                 "Grants player a bounded box they can build in",
                 Some(Admin),
             ),
-            ChatCommand::Players => cmd(vec![], "Lists players currently online", None),
-            ChatCommand::ReloadChunks => cmd(
+            ServerChatCommand::Players => cmd(vec![], "Lists players currently online", None),
+            ServerChatCommand::ReloadChunks => cmd(
                 vec![],
                 "Reloads all chunks loaded on the server",
                 Some(Admin),
             ),
-            ChatCommand::RemoveLights => cmd(
+            ServerChatCommand::RemoveLights => cmd(
                 vec![Float("radius", 20.0, Optional)],
                 "Removes all lights spawned by players",
                 Some(Admin),
             ),
-            ChatCommand::RevokeBuild => cmd(
+            ServerChatCommand::RevokeBuild => cmd(
                 vec![Any("area_name", Required)],
                 "Revokes build area permission for player",
                 Some(Admin),
             ),
-            ChatCommand::RevokeBuildAll => cmd(
+            ServerChatCommand::RevokeBuildAll => cmd(
                 vec![],
                 "Revokes all build area permissions for player",
                 Some(Admin),
             ),
-            ChatCommand::Region => cmd(
+            ServerChatCommand::Region => cmd(
                 vec![Message(Optional)],
                 "Send messages to everyone in your region of the world",
                 None,
             ),
-            ChatCommand::Safezone => cmd(
+            ServerChatCommand::Safezone => cmd(
                 vec![Float("range", 100.0, Optional)],
                 "Creates a safezone",
                 Some(Moderator),
             ),
-            ChatCommand::Say => cmd(
+            ServerChatCommand::Say => cmd(
                 vec![Message(Optional)],
                 "Send messages to everyone within shouting distance",
                 None,
             ),
-            ChatCommand::ServerPhysics => cmd(
+            ServerChatCommand::ServerPhysics => cmd(
                 vec![
                     PlayerName(Required),
                     Boolean("enabled", "true".to_string(), Optional),
@@ -583,24 +588,24 @@ impl ChatCommand {
                 "Set/unset server-authoritative physics for an account",
                 Some(Moderator),
             ),
-            ChatCommand::SetMotd => cmd(
+            ServerChatCommand::SetMotd => cmd(
                 vec![Message(Optional)],
                 "Set the server description",
                 Some(Admin),
             ),
-            ChatCommand::Ship => cmd(
+            ServerChatCommand::Ship => cmd(
                 vec![Float("destination_degrees_ccw_of_east", 90.0, Optional)],
                 "Spawns a ship",
                 Some(Admin),
             ),
             // Uses Message because site names can contain spaces,
             // which would be assumed to be separators otherwise
-            ChatCommand::Site => cmd(
+            ServerChatCommand::Site => cmd(
                 vec![SiteName(Required)],
                 "Teleport to a site",
                 Some(Moderator),
             ),
-            ChatCommand::SkillPoint => cmd(
+            ServerChatCommand::SkillPoint => cmd(
                 vec![
                     Enum("skill tree", SKILL_TREES.clone(), Required),
                     Integer("amount", 1, Optional),
@@ -608,12 +613,12 @@ impl ChatCommand {
                 "Give yourself skill points for a particular skill tree",
                 Some(Admin),
             ),
-            ChatCommand::SkillPreset => cmd(
+            ServerChatCommand::SkillPreset => cmd(
                 vec![Enum("preset_name", PRESET_LIST.to_vec(), Required)],
                 "Gives your character desired skills.",
                 Some(Admin),
             ),
-            ChatCommand::Spawn => cmd(
+            ServerChatCommand::Spawn => cmd(
                 vec![
                     Enum("alignment", ALIGNMENTS.clone(), Required),
                     Enum("entity", ENTITIES.clone(), Required),
@@ -623,58 +628,60 @@ impl ChatCommand {
                 "Spawn a test entity",
                 Some(Admin),
             ),
-            ChatCommand::Sudo => cmd(
+            ServerChatCommand::Sudo => cmd(
                 vec![PlayerName(Required), SubCommand],
                 "Run command as if you were another player",
                 Some(Moderator),
             ),
-            ChatCommand::Tell => cmd(
+            ServerChatCommand::Tell => cmd(
                 vec![PlayerName(Required), Message(Optional)],
                 "Send a message to another player",
                 None,
             ),
-            ChatCommand::Time => cmd(
+            ServerChatCommand::Time => cmd(
                 vec![Enum("time", TIMES.clone(), Optional)],
                 "Set the time of day",
                 Some(Admin),
             ),
-            ChatCommand::Tp => cmd(
+            ServerChatCommand::Tp => cmd(
                 vec![PlayerName(Optional)],
                 "Teleport to another player",
                 Some(Moderator),
             ),
-            ChatCommand::Unban => cmd(
+            ServerChatCommand::Unban => cmd(
                 vec![PlayerName(Required)],
                 "Remove the ban for the given username",
                 Some(Moderator),
             ),
-            ChatCommand::Version => cmd(vec![], "Prints server version", None),
-            ChatCommand::Waypoint => cmd(
+            ServerChatCommand::Version => cmd(vec![], "Prints server version", None),
+            ServerChatCommand::Waypoint => cmd(
                 vec![],
                 "Set your waypoint to your current position",
                 Some(Admin),
             ),
-            ChatCommand::Wiring => cmd(vec![], "Create wiring element", Some(Admin)),
-            ChatCommand::Whitelist => cmd(
+            ServerChatCommand::Wiring => cmd(vec![], "Create wiring element", Some(Admin)),
+            ServerChatCommand::Whitelist => cmd(
                 vec![Any("add/remove", Required), PlayerName(Required)],
                 "Adds/removes username to whitelist",
                 Some(Moderator),
             ),
-            ChatCommand::World => cmd(
+            ServerChatCommand::World => cmd(
                 vec![Message(Optional)],
                 "Send messages to everyone on the server",
                 None,
             ),
-            ChatCommand::MakeVolume => cmd(vec![], "Create a volume (experimental)", Some(Admin)),
-            ChatCommand::Location => {
+            ServerChatCommand::MakeVolume => {
+                cmd(vec![], "Create a volume (experimental)", Some(Admin))
+            },
+            ServerChatCommand::Location => {
                 cmd(vec![Any("name", Required)], "Teleport to a location", None)
             },
-            ChatCommand::CreateLocation => cmd(
+            ServerChatCommand::CreateLocation => cmd(
                 vec![Any("name", Required)],
                 "Create a location at the current position",
                 Some(Moderator),
             ),
-            ChatCommand::DeleteLocation => cmd(
+            ServerChatCommand::DeleteLocation => cmd(
                 vec![Any("name", Required)],
                 "Delete a location",
                 Some(Moderator),
@@ -682,80 +689,80 @@ impl ChatCommand {
         }
     }
 
-    /// The keyword used to invoke the command, omitting the leading '/'.
+    /// The keyword used to invoke the command, omitting the prefix.
     pub fn keyword(&self) -> &'static str {
         match self {
-            ChatCommand::Adminify => "adminify",
-            ChatCommand::Airship => "airship",
-            ChatCommand::Alias => "alias",
-            ChatCommand::ApplyBuff => "buff",
-            ChatCommand::Ban => "ban",
-            ChatCommand::BattleMode => "battlemode",
-            ChatCommand::BattleModeForce => "battlemode_force",
-            ChatCommand::Build => "build",
-            ChatCommand::BuildAreaAdd => "build_area_add",
-            ChatCommand::BuildAreaList => "build_area_list",
-            ChatCommand::BuildAreaRemove => "build_area_remove",
-            ChatCommand::Campfire => "campfire",
-            ChatCommand::DebugColumn => "debug_column",
-            ChatCommand::DisconnectAllPlayers => "disconnect_all_players",
-            ChatCommand::DropAll => "dropall",
-            ChatCommand::Dummy => "dummy",
-            ChatCommand::Explosion => "explosion",
-            ChatCommand::Faction => "faction",
-            ChatCommand::GiveItem => "give_item",
-            ChatCommand::Goto => "goto",
-            ChatCommand::Group => "group",
-            ChatCommand::GroupInvite => "group_invite",
-            ChatCommand::GroupKick => "group_kick",
-            ChatCommand::GroupPromote => "group_promote",
-            ChatCommand::GroupLeave => "group_leave",
-            ChatCommand::Health => "health",
-            ChatCommand::JoinFaction => "join_faction",
-            ChatCommand::Help => "help",
-            ChatCommand::Home => "home",
-            ChatCommand::Jump => "jump",
-            ChatCommand::Kick => "kick",
-            ChatCommand::Kill => "kill",
-            ChatCommand::Kit => "kit",
-            ChatCommand::KillNpcs => "kill_npcs",
-            ChatCommand::Lantern => "lantern",
-            ChatCommand::Light => "light",
-            ChatCommand::MakeBlock => "make_block",
-            ChatCommand::MakeNpc => "make_npc",
-            ChatCommand::MakeSprite => "make_sprite",
-            ChatCommand::Motd => "motd",
-            ChatCommand::Object => "object",
-            ChatCommand::PermitBuild => "permit_build",
-            ChatCommand::Players => "players",
-            ChatCommand::Region => "region",
-            ChatCommand::ReloadChunks => "reload_chunks",
-            ChatCommand::RemoveLights => "remove_lights",
-            ChatCommand::RevokeBuild => "revoke_build",
-            ChatCommand::RevokeBuildAll => "revoke_build_all",
-            ChatCommand::Safezone => "safezone",
-            ChatCommand::Say => "say",
-            ChatCommand::ServerPhysics => "server_physics",
-            ChatCommand::SetMotd => "set_motd",
-            ChatCommand::Ship => "ship",
-            ChatCommand::Site => "site",
-            ChatCommand::SkillPoint => "skill_point",
-            ChatCommand::SkillPreset => "skill_preset",
-            ChatCommand::Spawn => "spawn",
-            ChatCommand::Sudo => "sudo",
-            ChatCommand::Tell => "tell",
-            ChatCommand::Time => "time",
-            ChatCommand::Tp => "tp",
-            ChatCommand::Unban => "unban",
-            ChatCommand::Version => "version",
-            ChatCommand::Waypoint => "waypoint",
-            ChatCommand::Wiring => "wiring",
-            ChatCommand::Whitelist => "whitelist",
-            ChatCommand::World => "world",
-            ChatCommand::MakeVolume => "make_volume",
-            ChatCommand::Location => "location",
-            ChatCommand::CreateLocation => "create_location",
-            ChatCommand::DeleteLocation => "delete_location",
+            ServerChatCommand::Adminify => "adminify",
+            ServerChatCommand::Airship => "airship",
+            ServerChatCommand::Alias => "alias",
+            ServerChatCommand::ApplyBuff => "buff",
+            ServerChatCommand::Ban => "ban",
+            ServerChatCommand::BattleMode => "battlemode",
+            ServerChatCommand::BattleModeForce => "battlemode_force",
+            ServerChatCommand::Build => "build",
+            ServerChatCommand::BuildAreaAdd => "build_area_add",
+            ServerChatCommand::BuildAreaList => "build_area_list",
+            ServerChatCommand::BuildAreaRemove => "build_area_remove",
+            ServerChatCommand::Campfire => "campfire",
+            ServerChatCommand::DebugColumn => "debug_column",
+            ServerChatCommand::DisconnectAllPlayers => "disconnect_all_players",
+            ServerChatCommand::DropAll => "dropall",
+            ServerChatCommand::Dummy => "dummy",
+            ServerChatCommand::Explosion => "explosion",
+            ServerChatCommand::Faction => "faction",
+            ServerChatCommand::GiveItem => "give_item",
+            ServerChatCommand::Goto => "goto",
+            ServerChatCommand::Group => "group",
+            ServerChatCommand::GroupInvite => "group_invite",
+            ServerChatCommand::GroupKick => "group_kick",
+            ServerChatCommand::GroupPromote => "group_promote",
+            ServerChatCommand::GroupLeave => "group_leave",
+            ServerChatCommand::Health => "health",
+            ServerChatCommand::JoinFaction => "join_faction",
+            ServerChatCommand::Help => "help",
+            ServerChatCommand::Home => "home",
+            ServerChatCommand::Jump => "jump",
+            ServerChatCommand::Kick => "kick",
+            ServerChatCommand::Kill => "kill",
+            ServerChatCommand::Kit => "kit",
+            ServerChatCommand::KillNpcs => "kill_npcs",
+            ServerChatCommand::Lantern => "lantern",
+            ServerChatCommand::Light => "light",
+            ServerChatCommand::MakeBlock => "make_block",
+            ServerChatCommand::MakeNpc => "make_npc",
+            ServerChatCommand::MakeSprite => "make_sprite",
+            ServerChatCommand::Motd => "motd",
+            ServerChatCommand::Object => "object",
+            ServerChatCommand::PermitBuild => "permit_build",
+            ServerChatCommand::Players => "players",
+            ServerChatCommand::Region => "region",
+            ServerChatCommand::ReloadChunks => "reload_chunks",
+            ServerChatCommand::RemoveLights => "remove_lights",
+            ServerChatCommand::RevokeBuild => "revoke_build",
+            ServerChatCommand::RevokeBuildAll => "revoke_build_all",
+            ServerChatCommand::Safezone => "safezone",
+            ServerChatCommand::Say => "say",
+            ServerChatCommand::ServerPhysics => "server_physics",
+            ServerChatCommand::SetMotd => "set_motd",
+            ServerChatCommand::Ship => "ship",
+            ServerChatCommand::Site => "site",
+            ServerChatCommand::SkillPoint => "skill_point",
+            ServerChatCommand::SkillPreset => "skill_preset",
+            ServerChatCommand::Spawn => "spawn",
+            ServerChatCommand::Sudo => "sudo",
+            ServerChatCommand::Tell => "tell",
+            ServerChatCommand::Time => "time",
+            ServerChatCommand::Tp => "tp",
+            ServerChatCommand::Unban => "unban",
+            ServerChatCommand::Version => "version",
+            ServerChatCommand::Waypoint => "waypoint",
+            ServerChatCommand::Wiring => "wiring",
+            ServerChatCommand::Whitelist => "whitelist",
+            ServerChatCommand::World => "world",
+            ServerChatCommand::MakeVolume => "make_volume",
+            ServerChatCommand::Location => "location",
+            ServerChatCommand::CreateLocation => "create_location",
+            ServerChatCommand::DeleteLocation => "delete_location",
         }
     }
 
@@ -763,16 +770,19 @@ impl ChatCommand {
     /// Returns None if the command doesn't have a short keyword
     pub fn short_keyword(&self) -> Option<&'static str> {
         Some(match self {
-            ChatCommand::Faction => "f",
-            ChatCommand::Group => "g",
-            ChatCommand::Region => "r",
-            ChatCommand::Say => "s",
-            ChatCommand::Tell => "t",
-            ChatCommand::World => "w",
+            ServerChatCommand::Faction => "f",
+            ServerChatCommand::Group => "g",
+            ServerChatCommand::Region => "r",
+            ServerChatCommand::Say => "s",
+            ServerChatCommand::Tell => "t",
+            ServerChatCommand::World => "w",
             _ => return None,
         })
     }
 
+    /// Produce an iterator over all the available commands
+    pub fn iter() -> impl Iterator<Item = Self> { <Self as strum::IntoEnumIterator>::iter() }
+
     /// A message that explains what the command does
     pub fn help_string(&self) -> String {
         let data = self.data();
@@ -783,9 +793,17 @@ impl ChatCommand {
         format!("{}: {}", usage, data.description)
     }
 
-    /// A boolean that is used to check whether the command requires
-    /// administrator permissions or not.
-    pub fn needs_role(&self) -> Option<Role> { self.data().needs_role }
+    /// 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<Item = (&'static str, Self)> {
+        Self::iter()
+        // Go through all the shortcuts first
+        .filter_map(|c| c.short_keyword().map(|s| (s, c)))
+        .chain(Self::iter().map(|c| (c.keyword(), c)))
+    }
+
+    pub fn needs_role(&self) -> Option<comp::AdminRole> { self.data().needs_role }
 
     /// Returns a format string for parsing arguments with scan_fmt
     pub fn arg_fmt(&self) -> String {
@@ -807,34 +825,22 @@ impl ChatCommand {
             .collect::<Vec<_>>()
             .join(" ")
     }
-
-    /// Produce an iterator over all the available commands
-    pub fn iter() -> impl Iterator<Item = Self> { <Self as strum::IntoEnumIterator>::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<Item = (&'static str, Self)> {
-        Self::iter()
-            // Go through all the shortcuts first
-            .filter_map(|c| c.short_keyword().map(|s| (s, c)))
-            .chain(Self::iter().map(|c| (c.keyword(), c)))
-    }
 }
 
-impl Display for ChatCommand {
+impl Display for ServerChatCommand {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
         write!(f, "{}", self.keyword())
     }
 }
 
-impl FromStr for ChatCommand {
+impl FromStr for ServerChatCommand {
     type Err = ();
 
-    fn from_str(keyword: &str) -> Result<ChatCommand, ()> {
-        let keyword = keyword.strip_prefix('/').unwrap_or(keyword);
-
-        Self::iter_with_keywords()
+    fn from_str(keyword: &str) -> Result<ServerChatCommand, ()> {
+        Self::iter()
+        // Go through all the shortcuts first
+        .filter_map(|c| c.short_keyword().map(|s| (s, c)))
+        .chain(Self::iter().map(|c| (c.keyword(), c)))
             // Find command with matching string as keyword
             .find_map(|(kwd, command)| (kwd == keyword).then(|| command))
             // Return error if not found
@@ -956,6 +962,38 @@ impl ArgumentSpec {
     }
 }
 
+/// Parse a series of command arguments into values, including collecting all
+/// trailing arguments.
+#[macro_export]
+macro_rules! parse_cmd_args {
+    ($args:expr, $($t:ty),* $(, ..$tail:ty)? $(,)?) => {
+        {
+            let mut args = $args.into_iter().peekable();
+            (
+                // We only consume the input argument when parsing is successful. If this fails, we
+                // will then attempt to parse it as the next argument type. This is done regardless
+                // of whether the argument is optional because that information is not available
+                // here. Nevertheless, if the caller only precedes to use the parsed arguments when
+                // all required arguments parse successfully to `Some(val)` this should not create
+                // any unexpected behavior.
+                //
+                // This does mean that optional arguments will be included in the trailing args or
+                // that one optional arg could be interpreted as another, if the user makes a
+                // mistake that causes an optional arg to fail to parse. But there is no way to
+                // discern this in the current model with the optional args and trailing arg being
+                // solely position based.
+                $({
+                    let parsed = args.peek().and_then(|s| s.parse::<$t>().ok());
+                    // Consume successfully parsed arg.
+                    if parsed.is_some() { args.next(); }
+                    parsed
+                }),*
+                $(, args.map(|s| s.to_string()).collect::<$tail>())?
+            )
+        }
+    };
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/server/src/cmd.rs b/server/src/cmd.rs
index 1ef7b2d810..4b5edf8778 100644
--- a/server/src/cmd.rs
+++ b/server/src/cmd.rs
@@ -21,7 +21,7 @@ use common::{
     assets,
     calendar::Calendar,
     cmd::{
-        ChatCommand, KitSpec, BUFF_PACK, BUFF_PARSER, ITEM_SPECS, KIT_MANIFEST_PATH,
+        KitSpec, ServerChatCommand, BUFF_PACK, BUFF_PARSER, ITEM_SPECS, KIT_MANIFEST_PATH,
         PRESET_MANIFEST_PATH,
     },
     comp::{
@@ -39,6 +39,7 @@ use common::{
     link::Is,
     mounting::Rider,
     npc::{self, get_npc_name},
+    parse_cmd_args,
     resources::{BattleMode, PlayerPhysicsSettings, Time, TimeOfDay},
     terrain::{Block, BlockKind, SpriteKind, TerrainChunkSize},
     uid::{Uid, UidAllocator},
@@ -68,7 +69,7 @@ use tracing::{error, info, warn};
 pub trait ChatCommandExt {
     fn execute(&self, server: &mut Server, entity: EcsEntity, args: Vec<String>);
 }
-impl ChatCommandExt for ChatCommand {
+impl ChatCommandExt for ServerChatCommand {
     fn execute(&self, server: &mut Server, entity: EcsEntity, args: Vec<String>) {
         if let Err(err) = do_command(server, entity, entity, args, self) {
             server.notify_client(
@@ -100,14 +101,14 @@ type CmdResult<T> = Result<T, String>;
 /// failed; on failure, the string is sent to the client who initiated the
 /// command.
 type CommandHandler =
-    fn(&mut Server, EcsEntity, EcsEntity, Vec<String>, &ChatCommand) -> CmdResult<()>;
+    fn(&mut Server, EcsEntity, EcsEntity, Vec<String>, &ServerChatCommand) -> CmdResult<()>;
 
 fn do_command(
     server: &mut Server,
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    cmd: &ChatCommand,
+    cmd: &ServerChatCommand,
 ) -> CmdResult<()> {
     // Make sure your role is at least high enough to execute this command.
     if cmd.needs_role() > server.entity_admin_role(client) {
@@ -118,77 +119,77 @@ fn do_command(
     }
 
     let handler: CommandHandler = match cmd {
-        ChatCommand::Adminify => handle_adminify,
-        ChatCommand::Airship => handle_spawn_airship,
-        ChatCommand::Alias => handle_alias,
-        ChatCommand::ApplyBuff => handle_apply_buff,
-        ChatCommand::Ban => handle_ban,
-        ChatCommand::BattleMode => handle_battlemode,
-        ChatCommand::BattleModeForce => handle_battlemode_force,
-        ChatCommand::Build => handle_build,
-        ChatCommand::BuildAreaAdd => handle_build_area_add,
-        ChatCommand::BuildAreaList => handle_build_area_list,
-        ChatCommand::BuildAreaRemove => handle_build_area_remove,
-        ChatCommand::Campfire => handle_spawn_campfire,
-        ChatCommand::DebugColumn => handle_debug_column,
-        ChatCommand::DisconnectAllPlayers => handle_disconnect_all_players,
-        ChatCommand::DropAll => handle_drop_all,
-        ChatCommand::Dummy => handle_spawn_training_dummy,
-        ChatCommand::Explosion => handle_explosion,
-        ChatCommand::Faction => handle_faction,
-        ChatCommand::GiveItem => handle_give_item,
-        ChatCommand::Goto => handle_goto,
-        ChatCommand::Group => handle_group,
-        ChatCommand::GroupInvite => handle_group_invite,
-        ChatCommand::GroupKick => handle_group_kick,
-        ChatCommand::GroupLeave => handle_group_leave,
-        ChatCommand::GroupPromote => handle_group_promote,
-        ChatCommand::Health => handle_health,
-        ChatCommand::Help => handle_help,
-        ChatCommand::Home => handle_home,
-        ChatCommand::JoinFaction => handle_join_faction,
-        ChatCommand::Jump => handle_jump,
-        ChatCommand::Kick => handle_kick,
-        ChatCommand::Kill => handle_kill,
-        ChatCommand::KillNpcs => handle_kill_npcs,
-        ChatCommand::Kit => handle_kit,
-        ChatCommand::Lantern => handle_lantern,
-        ChatCommand::Light => handle_light,
-        ChatCommand::MakeBlock => handle_make_block,
-        ChatCommand::MakeNpc => handle_make_npc,
-        ChatCommand::MakeSprite => handle_make_sprite,
-        ChatCommand::Motd => handle_motd,
-        ChatCommand::Object => handle_object,
-        ChatCommand::PermitBuild => handle_permit_build,
-        ChatCommand::Players => handle_players,
-        ChatCommand::Region => handle_region,
-        ChatCommand::ReloadChunks => handle_reload_chunks,
-        ChatCommand::RemoveLights => handle_remove_lights,
-        ChatCommand::RevokeBuild => handle_revoke_build,
-        ChatCommand::RevokeBuildAll => handle_revoke_build_all,
-        ChatCommand::Safezone => handle_safezone,
-        ChatCommand::Say => handle_say,
-        ChatCommand::ServerPhysics => handle_server_physics,
-        ChatCommand::SetMotd => handle_set_motd,
-        ChatCommand::Ship => handle_spawn_ship,
-        ChatCommand::Site => handle_site,
-        ChatCommand::SkillPoint => handle_skill_point,
-        ChatCommand::SkillPreset => handle_skill_preset,
-        ChatCommand::Spawn => handle_spawn,
-        ChatCommand::Sudo => handle_sudo,
-        ChatCommand::Tell => handle_tell,
-        ChatCommand::Time => handle_time,
-        ChatCommand::Tp => handle_tp,
-        ChatCommand::Unban => handle_unban,
-        ChatCommand::Version => handle_version,
-        ChatCommand::Waypoint => handle_waypoint,
-        ChatCommand::Wiring => handle_spawn_wiring,
-        ChatCommand::Whitelist => handle_whitelist,
-        ChatCommand::World => handle_world,
-        ChatCommand::MakeVolume => handle_make_volume,
-        ChatCommand::Location => handle_location,
-        ChatCommand::CreateLocation => handle_create_location,
-        ChatCommand::DeleteLocation => handle_delete_location,
+        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::Build => handle_build,
+        ServerChatCommand::BuildAreaAdd => handle_build_area_add,
+        ServerChatCommand::BuildAreaList => handle_build_area_list,
+        ServerChatCommand::BuildAreaRemove => handle_build_area_remove,
+        ServerChatCommand::Campfire => handle_spawn_campfire,
+        ServerChatCommand::DebugColumn => handle_debug_column,
+        ServerChatCommand::DisconnectAllPlayers => handle_disconnect_all_players,
+        ServerChatCommand::DropAll => handle_drop_all,
+        ServerChatCommand::Dummy => handle_spawn_training_dummy,
+        ServerChatCommand::Explosion => handle_explosion,
+        ServerChatCommand::Faction => handle_faction,
+        ServerChatCommand::GiveItem => handle_give_item,
+        ServerChatCommand::Goto => handle_goto,
+        ServerChatCommand::Group => handle_group,
+        ServerChatCommand::GroupInvite => handle_group_invite,
+        ServerChatCommand::GroupKick => handle_group_kick,
+        ServerChatCommand::GroupLeave => handle_group_leave,
+        ServerChatCommand::GroupPromote => handle_group_promote,
+        ServerChatCommand::Health => handle_health,
+        ServerChatCommand::Help => handle_help,
+        ServerChatCommand::Home => handle_home,
+        ServerChatCommand::JoinFaction => handle_join_faction,
+        ServerChatCommand::Jump => handle_jump,
+        ServerChatCommand::Kick => handle_kick,
+        ServerChatCommand::Kill => handle_kill,
+        ServerChatCommand::KillNpcs => handle_kill_npcs,
+        ServerChatCommand::Kit => handle_kit,
+        ServerChatCommand::Lantern => handle_lantern,
+        ServerChatCommand::Light => handle_light,
+        ServerChatCommand::MakeBlock => handle_make_block,
+        ServerChatCommand::MakeNpc => handle_make_npc,
+        ServerChatCommand::MakeSprite => handle_make_sprite,
+        ServerChatCommand::Motd => handle_motd,
+        ServerChatCommand::Object => handle_object,
+        ServerChatCommand::PermitBuild => handle_permit_build,
+        ServerChatCommand::Players => handle_players,
+        ServerChatCommand::Region => handle_region,
+        ServerChatCommand::ReloadChunks => handle_reload_chunks,
+        ServerChatCommand::RemoveLights => handle_remove_lights,
+        ServerChatCommand::RevokeBuild => handle_revoke_build,
+        ServerChatCommand::RevokeBuildAll => handle_revoke_build_all,
+        ServerChatCommand::Safezone => handle_safezone,
+        ServerChatCommand::Say => handle_say,
+        ServerChatCommand::ServerPhysics => handle_server_physics,
+        ServerChatCommand::SetMotd => handle_set_motd,
+        ServerChatCommand::Ship => handle_spawn_ship,
+        ServerChatCommand::Site => handle_site,
+        ServerChatCommand::SkillPoint => handle_skill_point,
+        ServerChatCommand::SkillPreset => handle_skill_preset,
+        ServerChatCommand::Spawn => handle_spawn,
+        ServerChatCommand::Sudo => handle_sudo,
+        ServerChatCommand::Tell => handle_tell,
+        ServerChatCommand::Time => handle_time,
+        ServerChatCommand::Tp => handle_tp,
+        ServerChatCommand::Unban => handle_unban,
+        ServerChatCommand::Version => handle_version,
+        ServerChatCommand::Waypoint => handle_waypoint,
+        ServerChatCommand::Wiring => handle_spawn_wiring,
+        ServerChatCommand::Whitelist => handle_whitelist,
+        ServerChatCommand::World => handle_world,
+        ServerChatCommand::MakeVolume => handle_make_volume,
+        ServerChatCommand::Location => handle_location,
+        ServerChatCommand::CreateLocation => handle_create_location,
+        ServerChatCommand::DeleteLocation => handle_delete_location,
     };
 
     handler(server, client, target, args, cmd)
@@ -440,43 +441,12 @@ fn edit_setting_feedback<S: EditableSetting>(
     }
 }
 
-/// Parse a series of command arguments into values, including collecting all
-/// trailing arguments.
-macro_rules! parse_args {
-    ($args:expr, $($t:ty),* $(, ..$tail:ty)? $(,)?) => {
-        {
-            let mut args = $args.into_iter().peekable();
-            (
-                // We only consume the input argument when parsing is successful. If this fails, we
-                // will then attempt to parse it as the next argument type. This is done regardless
-                // of whether the argument is optional because that information is not available
-                // here. Nevertheless, if the caller only precedes to use the parsed arguments when
-                // all required arguments parse successfully to `Some(val)` this should not create
-                // any unexpected behavior.
-                //
-                // This does mean that optional arguments will be included in the trailing args or
-                // that one optional arg could be interpreted as another, if the user makes a
-                // mistake that causes an optional arg to fail to parse. But there is no way to
-                // discern this in the current model with the optional args and trailing arg being
-                // solely position based.
-                $({
-                    let parsed = args.peek().and_then(|s| s.parse::<$t>().ok());
-                    // Consume successfully parsed arg.
-                    if parsed.is_some() { args.next(); }
-                    parsed
-                }),*
-                $(, args.map(|s| s.to_string()).collect::<$tail>())?
-            )
-        }
-    };
-}
-
 fn handle_drop_all(
     server: &mut Server,
     _client: EcsEntity,
     target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let pos = position(server, target, "target")?;
 
@@ -518,9 +488,9 @@ fn handle_give_item(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let (Some(item_name), give_amount_opt) = parse_args!(args, String, u32) {
+    if let (Some(item_name), give_amount_opt) = parse_cmd_args!(args, String, u32) {
         let give_amount = give_amount_opt.unwrap_or(1);
         if let Ok(item) = Item::new_from_asset(&item_name.replace('/', ".").replace('\\', ".")) {
             let mut item: Item = item;
@@ -592,9 +562,9 @@ fn handle_make_block(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let (Some(block_name), r, g, b) = parse_args!(args, String, u8, u8, u8) {
+    if let (Some(block_name), r, g, b) = parse_cmd_args!(args, String, u8, u8, u8) {
         if let Ok(bk) = BlockKind::from_str(block_name.as_str()) {
             let pos = position(server, target, "target")?;
             let new_block = Block::new(bk, Rgb::new(r, g, b).map(|e| e.unwrap_or(255)));
@@ -623,9 +593,9 @@ fn handle_make_npc(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    let (entity_config, number) = parse_args!(args, String, i8);
+    let (entity_config, number) = parse_cmd_args!(args, String, i8);
 
     let entity_config = entity_config.ok_or_else(|| action.help_string())?;
     let number = match number {
@@ -716,9 +686,9 @@ fn handle_make_sprite(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let Some(sprite_name) = parse_args!(args, String) {
+    if let Some(sprite_name) = parse_cmd_args!(args, String) {
         if let Ok(sk) = SpriteKind::try_from(sprite_name.as_str()) {
             let pos = position(server, target, "target")?;
             let pos = pos.0.map(|e| e.floor() as i32);
@@ -752,7 +722,7 @@ fn handle_motd(
     client: EcsEntity,
     _target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     server.notify_client(
         client,
@@ -769,14 +739,14 @@ fn handle_set_motd(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let data_dir = server.data_dir();
     let client_uuid = uuid(server, client, "client")?;
     // Ensure the person setting this has a real role in the settings file, since
     // it's persistent.
     let _client_real_role = real_role(server, client_uuid, "client")?;
-    match parse_args!(args, String) {
+    match parse_cmd_args!(args, String) {
         Some(msg) => {
             let edit =
                 server
@@ -814,9 +784,9 @@ fn handle_jump(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let (Some(x), Some(y), Some(z)) = parse_args!(args, f32, f32, f32) {
+    if let (Some(x), Some(y), Some(z)) = parse_cmd_args!(args, f32, f32, f32) {
         position_mut(server, target, "target", |current_pos| {
             current_pos.0 += Vec3::new(x, y, z)
         })
@@ -830,9 +800,9 @@ fn handle_goto(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let (Some(x), Some(y), Some(z)) = parse_args!(args, f32, f32, f32) {
+    if let (Some(x), Some(y), Some(z)) = parse_cmd_args!(args, f32, f32, f32) {
         position_mut(server, target, "target", |current_pos| {
             current_pos.0 = Vec3::new(x, y, z)
         })
@@ -848,10 +818,10 @@ fn handle_site(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
     #[cfg(feature = "worldgen")]
-    if let Some(dest_name) = parse_args!(args, String) {
+    if let Some(dest_name) = parse_cmd_args!(args, String) {
         let site = server
             .world
             .civs()
@@ -884,7 +854,7 @@ fn handle_home(
     _client: EcsEntity,
     target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let home_pos = server.state.mut_resource::<SpawnPoint>().0;
     let time = *server.state.mut_resource::<common::resources::Time>();
@@ -905,7 +875,7 @@ fn handle_kill(
     _client: EcsEntity,
     target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     server
         .state
@@ -921,7 +891,7 @@ fn handle_time(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     const DAY: u64 = 86400;
 
@@ -940,7 +910,7 @@ fn handle_time(
             }
     };
 
-    let time = parse_args!(args, String);
+    let time = parse_cmd_args!(args, String);
     let new_time = match time.as_deref() {
         Some("midnight") => {
             next_cycle(NaiveTime::from_hms(0, 0, 0).num_seconds_from_midnight() as f64)
@@ -1072,9 +1042,9 @@ fn handle_health(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let Some(hp) = parse_args!(args, f32) {
+    if let Some(hp) = parse_cmd_args!(args, f32) {
         if let Some(mut health) = server
             .state
             .ecs()
@@ -1103,9 +1073,9 @@ fn handle_alias(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let Some(alias) = parse_args!(args, String) {
+    if let Some(alias) = parse_cmd_args!(args, String) {
         // Prevent silly aliases
         comp::Player::alias_validate(&alias).map_err(|e| e.to_string())?;
 
@@ -1155,9 +1125,9 @@ fn handle_tp(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    let player = if let Some(alias) = parse_args!(args, String) {
+    let player = if let Some(alias) = parse_cmd_args!(args, String) {
         find_alias(server.state.ecs(), &alias)?.0
     } else if client != target {
         client
@@ -1175,9 +1145,9 @@ fn handle_spawn(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    match parse_args!(args, String, npc::NpcBody, u32, bool) {
+    match parse_cmd_args!(args, String, npc::NpcBody, u32, bool) {
         (Some(opt_align), Some(npc::NpcBody(id, mut body)), opt_amount, opt_ai) => {
             let uid = uid(server, target, "target")?;
             let alignment = parse_alignment(uid, &opt_align)?;
@@ -1269,7 +1239,7 @@ fn handle_spawn_training_dummy(
     client: EcsEntity,
     target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let pos = position(server, target, "target")?;
     let vel = Vec3::new(
@@ -1311,9 +1281,9 @@ fn handle_spawn_airship(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    let angle = parse_args!(args, f32);
+    let angle = parse_cmd_args!(args, f32);
     let mut pos = position(server, target, "target")?;
     pos.0.z += 50.0;
     const DESTINATION_RADIUS: f32 = 2000.0;
@@ -1359,9 +1329,9 @@ fn handle_spawn_ship(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    let angle = parse_args!(args, f32);
+    let angle = parse_cmd_args!(args, f32);
     let mut pos = position(server, target, "target")?;
     pos.0.z += 50.0;
     const DESTINATION_RADIUS: f32 = 2000.0;
@@ -1407,7 +1377,7 @@ fn handle_make_volume(
     client: EcsEntity,
     target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     use comp::body::ship::figuredata::VoxelCollider;
 
@@ -1447,7 +1417,7 @@ fn handle_spawn_campfire(
     client: EcsEntity,
     target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let pos = position(server, target, "target")?;
     server
@@ -1498,9 +1468,9 @@ fn handle_safezone(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    let range = parse_args!(args, f32);
+    let range = parse_cmd_args!(args, f32);
     let pos = position(server, target, "target")?;
     server.state.create_safezone(range, pos).build();
 
@@ -1516,9 +1486,9 @@ fn handle_permit_build(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let Some(area_name) = parse_args!(args, String) {
+    if let Some(area_name) = parse_cmd_args!(args, String) {
         let bb_id = area(server, &area_name)?;
         let mut can_build = server.state.ecs().write_storage::<comp::CanBuild>();
         let entry = can_build
@@ -1557,9 +1527,9 @@ fn handle_revoke_build(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let Some(area_name) = parse_args!(args, String) {
+    if let Some(area_name) = parse_cmd_args!(args, String) {
         let bb_id = area(server, &area_name)?;
         let mut can_build = server.state.ecs_mut().write_storage::<comp::CanBuild>();
         if let Some(mut comp_can_build) = can_build.get_mut(target) {
@@ -1595,7 +1565,7 @@ fn handle_revoke_build_all(
     client: EcsEntity,
     target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let ecs = server.state.ecs();
 
@@ -1621,7 +1591,7 @@ fn handle_players(
     client: EcsEntity,
     _target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let ecs = server.state.ecs();
 
@@ -1649,7 +1619,7 @@ fn handle_build(
     client: EcsEntity,
     target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     if let Some(mut can_build) = server
         .state
@@ -1689,10 +1659,10 @@ fn handle_build_area_add(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
     if let (Some(area_name), Some(xlo), Some(xhi), Some(ylo), Some(yhi), Some(zlo), Some(zhi)) =
-        parse_args!(args, String, i32, i32, i32, i32, i32, i32)
+        parse_cmd_args!(args, String, i32, i32, i32, i32, i32, i32)
     {
         let build_areas = server.state.mut_resource::<BuildAreas>();
         let msg = ServerGeneral::server_msg(
@@ -1717,7 +1687,7 @@ fn handle_build_area_list(
     client: EcsEntity,
     _target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let build_areas = server.state.mut_resource::<BuildAreas>();
     let msg = ServerGeneral::server_msg(
@@ -1743,9 +1713,9 @@ fn handle_build_area_remove(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let Some(area_name) = parse_args!(args, String) {
+    if let Some(area_name) = parse_cmd_args!(args, String) {
         let build_areas = server.state.mut_resource::<BuildAreas>();
 
         build_areas.remove(&area_name).map_err(|err| match err {
@@ -1773,9 +1743,9 @@ fn handle_help(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let Some(cmd) = parse_args!(args, ChatCommand) {
+    if let Some(cmd) = parse_cmd_args!(args, ServerChatCommand) {
         server.notify_client(
             client,
             ServerGeneral::server_msg(ChatType::CommandInfo, cmd.help_string()),
@@ -1785,14 +1755,14 @@ fn handle_help(
         let entity_role = server.entity_admin_role(client);
 
         // Iterate through all commands you have permission to use.
-        ChatCommand::iter()
+        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:";
-        ChatCommand::iter()
+        ServerChatCommand::iter()
             .filter_map(|cmd| cmd.short_keyword().map(|k| (k, cmd)))
             .for_each(|(k, cmd)| {
                 message += &format!(" /{} => /{}", k, cmd.keyword());
@@ -1821,9 +1791,9 @@ fn handle_kill_npcs(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    let kill_pets = if let Some(kill_option) = parse_args!(args, String) {
+    let kill_pets = if let Some(kill_option) = parse_cmd_args!(args, String) {
         kill_option.contains("--also-pets")
     } else {
         false
@@ -1869,7 +1839,7 @@ fn handle_kit(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
     use common::cmd::KitManifest;
 
@@ -1879,7 +1849,7 @@ fn handle_kit(
             ServerGeneral::server_msg(ChatType::CommandInfo, format!("Gave kit: {}", kit_name)),
         );
     };
-    let name = parse_args!(args, String).ok_or_else(|| action.help_string())?;
+    let name = parse_cmd_args!(args, String).ok_or_else(|| action.help_string())?;
 
     match name.as_str() {
         "all" => {
@@ -1989,9 +1959,9 @@ fn handle_object(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    let obj_type = parse_args!(args, String);
+    let obj_type = parse_cmd_args!(args, String);
 
     let pos = position(server, target, "target")?;
     let ori = server
@@ -2048,10 +2018,10 @@ fn handle_light(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let (opt_r, opt_g, opt_b, opt_x, opt_y, opt_z, opt_s) =
-        parse_args!(args, f32, f32, f32, f32, f32, f32, f32);
+        parse_cmd_args!(args, f32, f32, f32, f32, f32, f32, f32);
 
     let mut light_emitter = comp::LightEmitter::default();
     let mut light_offset_opt = None;
@@ -2101,9 +2071,9 @@ fn handle_lantern(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let (Some(s), r, g, b) = parse_args!(args, f32, f32, f32, f32) {
+    if let (Some(s), r, g, b) = parse_cmd_args!(args, f32, f32, f32, f32) {
         if let Some(mut light) = server
             .state
             .ecs()
@@ -2148,9 +2118,9 @@ fn handle_explosion(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    let power = parse_args!(args, f32).unwrap_or(8.0);
+    let power = parse_cmd_args!(args, f32).unwrap_or(8.0);
 
     const MIN_POWER: f32 = 0.0;
     const MAX_POWER: f32 = 512.0;
@@ -2202,7 +2172,7 @@ fn handle_waypoint(
     client: EcsEntity,
     target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let pos = position(server, target, "target")?;
     let time = *server.state.mut_resource::<common::resources::Time>();
@@ -2228,7 +2198,7 @@ fn handle_spawn_wiring(
     client: EcsEntity,
     target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     // Obviously it is a WIP - use it for debug
 
@@ -2397,9 +2367,9 @@ fn handle_adminify(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let (Some(alias), desired_role) = parse_args!(args, String, String) {
+    if let (Some(alias), desired_role) = parse_cmd_args!(args, String, String) {
         let desired_role = if let Some(mut desired_role) = desired_role {
             desired_role.make_ascii_lowercase();
             Some(match &*desired_role {
@@ -2507,11 +2477,11 @@ fn handle_tell(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
     no_sudo(client, target)?;
 
-    if let (Some(alias), message_opt) = parse_args!(args, String, ..Vec<String>) {
+    if let (Some(alias), message_opt) = parse_cmd_args!(args, String, ..Vec<String>) {
         let ecs = server.state.ecs();
         let player = find_alias(ecs, &alias)?.0;
 
@@ -2540,7 +2510,7 @@ fn handle_faction(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     no_sudo(client, target)?;
 
@@ -2567,7 +2537,7 @@ fn handle_group(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     no_sudo(client, target)?;
 
@@ -2594,9 +2564,9 @@ fn handle_group_invite(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let Some(target_alias) = parse_args!(args, String) {
+    if let Some(target_alias) = parse_cmd_args!(args, String) {
         let target_player = find_alias(server.state.ecs(), &target_alias)?.0;
         let uid = uid(server, target_player, "player")?;
 
@@ -2633,10 +2603,10 @@ fn handle_group_kick(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
     // Checking if leader is already done in group_manip
-    if let Some(target_alias) = parse_args!(args, String) {
+    if let Some(target_alias) = parse_cmd_args!(args, String) {
         let target_player = find_alias(server.state.ecs(), &target_alias)?.0;
         let uid = uid(server, target_player, "player")?;
 
@@ -2655,7 +2625,7 @@ fn handle_group_leave(
     _client: EcsEntity,
     target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     server
         .state
@@ -2669,10 +2639,10 @@ fn handle_group_promote(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
     // Checking if leader is already done in group_manip
-    if let Some(target_alias) = parse_args!(args, String) {
+    if let Some(target_alias) = parse_cmd_args!(args, String) {
         let target_player = find_alias(server.state.ecs(), &target_alias)?.0;
         let uid = uid(server, target_player, "player")?;
 
@@ -2694,7 +2664,7 @@ fn handle_region(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     no_sudo(client, target)?;
 
@@ -2715,7 +2685,7 @@ fn handle_say(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     no_sudo(client, target)?;
 
@@ -2736,7 +2706,7 @@ fn handle_world(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     no_sudo(client, target)?;
 
@@ -2757,12 +2727,12 @@ fn handle_join_faction(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let players = server.state.ecs().read_storage::<comp::Player>();
     if let Some(alias) = players.get(target).map(|player| player.alias.clone()) {
         drop(players);
-        let (faction_leave, mode) = if let Some(faction) = parse_args!(args, String) {
+        let (faction_leave, mode) = if let Some(faction) = parse_cmd_args!(args, String) {
             let mode = comp::ChatMode::Faction(faction.clone());
             insert_or_replace_component(server, target, mode.clone(), "target")?;
             let faction_join = server
@@ -2808,7 +2778,7 @@ fn handle_debug_column(
     client: EcsEntity,
     target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     Err("Unsupported without worldgen enabled".into())
 }
@@ -2819,12 +2789,12 @@ fn handle_debug_column(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let sim = server.world.sim();
     let calendar = (*server.state.ecs().read_resource::<Calendar>()).clone();
     let sampler = server.world.sample_columns();
-    let wpos = if let (Some(x), Some(y)) = parse_args!(args, i32, i32) {
+    let wpos = if let (Some(x), Some(y)) = parse_cmd_args!(args, i32, i32) {
         Vec2::new(x, y)
     } else {
         let pos = position(server, target, "target")?;
@@ -2895,13 +2865,13 @@ fn handle_disconnect_all_players(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let client_uuid = uuid(server, client, "client")?;
     // Make sure temporary mods/admins can't run this command.
     let _role = real_role(server, client_uuid, "role")?;
 
-    if parse_args!(args, String).as_deref() != Some("confirm") {
+    if parse_cmd_args!(args, String).as_deref() != Some("confirm") {
         return Err(
             "Please run the command again with the second argument of \"confirm\" to confirm that \
              you really want to disconnect all players from the server"
@@ -2939,9 +2909,9 @@ fn handle_skill_point(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let (Some(a_skill_tree), Some(sp), a_alias) = parse_args!(args, String, u16, String) {
+    if let (Some(a_skill_tree), Some(sp), a_alias) = parse_cmd_args!(args, String, u16, String) {
         let skill_tree = parse_skill_tree(&a_skill_tree)?;
         let player = a_alias
             .map(|alias| find_alias(server.state.ecs(), &alias).map(|(target, _)| target))
@@ -2983,7 +2953,7 @@ fn handle_reload_chunks(
     _client: EcsEntity,
     _target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     server.state.clear_terrain();
 
@@ -2995,9 +2965,9 @@ fn handle_remove_lights(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    let opt_radius = parse_args!(args, f32);
+    let opt_radius = parse_cmd_args!(args, f32);
     let player_pos = position(server, target, "target")?;
     let mut to_delete = vec![];
 
@@ -3039,10 +3009,10 @@ fn handle_sudo(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
     if let (Some(player_alias), Some(cmd), cmd_args) =
-        parse_args!(args, String, String, ..Vec<String>)
+        parse_cmd_args!(args, String, String, ..Vec<String>)
     {
         if let Ok(action) = cmd.parse() {
             let (player, player_uuid) = find_alias(server.state.ecs(), &player_alias)?;
@@ -3071,7 +3041,7 @@ fn handle_version(
     client: EcsEntity,
     _target: EcsEntity,
     _args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     server.notify_client(
         client,
@@ -3092,11 +3062,11 @@ fn handle_whitelist(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let now = Utc::now();
 
-    if let (Some(whitelist_action), Some(username)) = parse_args!(args, String, String) {
+    if let (Some(whitelist_action), Some(username)) = parse_cmd_args!(args, String, String) {
         let client_uuid = uuid(server, client, "client")?;
         let client_username = uuid_to_username(server, client, client_uuid)?;
         let client_role = real_role(server, client_uuid, "client")?;
@@ -3190,9 +3160,9 @@ fn handle_kick(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let (Some(target_alias), reason_opt) = parse_args!(args, String, String) {
+    if let (Some(target_alias), reason_opt) = parse_cmd_args!(args, String, String) {
         let client_uuid = uuid(server, client, "client")?;
         let reason = reason_opt.unwrap_or_default();
         let ecs = server.state.ecs();
@@ -3220,10 +3190,10 @@ fn handle_ban(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
     if let (Some(username), overwrite, parse_duration, reason_opt) =
-        parse_args!(args, String, bool, HumanDuration, String)
+        parse_cmd_args!(args, String, bool, HumanDuration, String)
     {
         let reason = reason_opt.unwrap_or_default();
         let overwrite = overwrite.unwrap_or(false);
@@ -3298,7 +3268,7 @@ fn handle_battlemode(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
     // TODO: discuss time
     const COOLDOWN: f64 = 60.0 * 5.0;
@@ -3306,7 +3276,7 @@ fn handle_battlemode(
     let ecs = server.state.ecs();
     let time = ecs.read_resource::<Time>();
     let settings = ecs.read_resource::<Settings>();
-    if let Some(mode) = parse_args!(args, String) {
+    if let Some(mode) = parse_cmd_args!(args, String) {
         if !settings.gameplay.battle_mode.allow_choosing() {
             return Err("Command disabled in server settings".to_owned());
         }
@@ -3404,14 +3374,14 @@ fn handle_battlemode_force(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
     let ecs = server.state.ecs();
     let settings = ecs.read_resource::<Settings>();
     if !settings.gameplay.battle_mode.allow_choosing() {
         return Err("Command disabled in server settings".to_owned());
     }
-    let mode = parse_args!(args, String).ok_or_else(|| action.help_string())?;
+    let mode = parse_cmd_args!(args, String).ok_or_else(|| action.help_string())?;
     let mode = match mode.as_str() {
         "pvp" => BattleMode::PvP,
         "pve" => BattleMode::PvE,
@@ -3437,9 +3407,9 @@ fn handle_unban(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let Some(username) = parse_args!(args, String) {
+    if let Some(username) = parse_cmd_args!(args, String) {
         let player_uuid = find_username(server, &username)?;
 
         let client_uuid = uuid(server, client, "client")?;
@@ -3482,9 +3452,9 @@ fn handle_server_physics(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let (Some(username), enabled_opt) = parse_args!(args, String, bool) {
+    if let (Some(username), enabled_opt) = parse_cmd_args!(args, String, bool) {
         let uuid = find_username(server, &username)?;
         let server_force = enabled_opt.unwrap_or(true);
 
@@ -3514,9 +3484,9 @@ fn handle_apply_buff(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let (Some(buff), strength, duration) = parse_args!(args, String, f32, f64) {
+    if let (Some(buff), strength, duration) = parse_cmd_args!(args, String, f32, f64) {
         let strength = strength.unwrap_or(0.01);
         let duration = Duration::from_secs_f64(duration.unwrap_or(1.0));
         let buffdata = BuffData::new(strength, Some(duration));
@@ -3553,9 +3523,9 @@ fn handle_skill_preset(
     _client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let Some(preset) = parse_args!(args, String) {
+    if let Some(preset) = parse_cmd_args!(args, String) {
         if let Some(mut skill_set) = server
             .state
             .ecs_mut()
@@ -3615,9 +3585,9 @@ fn handle_location(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    _action: &ChatCommand,
+    _action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let Some(name) = parse_args!(args, String) {
+    if let Some(name) = parse_cmd_args!(args, String) {
         let loc = server.state.ecs().read_resource::<Locations>().get(&name);
         match loc {
             Ok(loc) => position_mut(server, target, "target", |target_pos| {
@@ -3649,9 +3619,9 @@ fn handle_create_location(
     client: EcsEntity,
     target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let Some(name) = parse_args!(args, String) {
+    if let Some(name) = parse_cmd_args!(args, String) {
         let target_pos = position(server, target, "target")?;
 
         let res = server
@@ -3682,9 +3652,9 @@ fn handle_delete_location(
     client: EcsEntity,
     _target: EcsEntity,
     args: Vec<String>,
-    action: &ChatCommand,
+    action: &ServerChatCommand,
 ) -> CmdResult<()> {
-    if let Some(name) = parse_args!(args, String) {
+    if let Some(name) = parse_cmd_args!(args, String) {
         let res = server
             .state
             .ecs_mut()
diff --git a/server/src/events/player.rs b/server/src/events/player.rs
index 0f63e53625..6af4ffbbcd 100644
--- a/server/src/events/player.rs
+++ b/server/src/events/player.rs
@@ -431,6 +431,7 @@ pub fn handle_possess(server: &mut Server, possessor_uid: Uid, possessee_uid: Ui
                             name: s.name.clone(),
                         }
                     }),
+                    uuid: player.uuid(),
                 }),
             );
             let remove_player_msg = ServerGeneral::PlayerListUpdate(
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 4659d9cdc1..9b7be24626 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -73,7 +73,7 @@ use common::{
     assets::AssetExt,
     calendar::Calendar,
     character::CharacterId,
-    cmd::ChatCommand,
+    cmd::ServerChatCommand,
     comp,
     event::{EventBus, ServerEvent},
     recipe::{default_component_recipe_book, default_recipe_book},
@@ -1210,7 +1210,7 @@ impl Server {
 
     fn process_command(&mut self, entity: EcsEntity, name: String, args: Vec<String>) {
         // Find the command object and run its handler.
-        if let Ok(command) = name.parse::<ChatCommand>() {
+        if let Ok(command) = name.parse::<ServerChatCommand>() {
             command.execute(self, entity, args);
         } else {
             #[cfg(feature = "plugins")]
diff --git a/server/src/sys/msg/register.rs b/server/src/sys/msg/register.rs
index 5c8fe41568..3b0a1b047d 100644
--- a/server/src/sys/msg/register.rs
+++ b/server/src/sys/msg/register.rs
@@ -88,6 +88,7 @@ impl<'a> System<'a> for Sys {
                     character: stats.map(|stats| CharacterInfo {
                         name: stats.name.clone(),
                     }),
+                    uuid: player.uuid(),
                 })
             })
             .collect::<HashMap<_, _>>();
@@ -239,6 +240,7 @@ impl<'a> System<'a> for Sys {
                             is_online: true,
                             is_moderator: admins.get(entity).is_some(),
                             character: None, // new players will be on character select.
+                            uuid: player.uuid(),
                         }),
                     )));
                 }
diff --git a/voxygen/egui/src/admin.rs b/voxygen/egui/src/admin.rs
index b5a16bc49b..284b024d1a 100644
--- a/voxygen/egui/src/admin.rs
+++ b/voxygen/egui/src/admin.rs
@@ -1,5 +1,5 @@
 use crate::{AdminCommandState, EguiAction, EguiActions};
-use common::cmd::ChatCommand;
+use common::cmd::ServerChatCommand;
 use egui::{CollapsingHeader, CtxRef, Resize, Slider, Ui, Vec2, Window};
 use lazy_static::lazy_static;
 
@@ -45,7 +45,7 @@ fn draw_kits(ui: &mut Ui, state: &mut AdminCommandState, egui_actions: &mut Egui
     ui.vertical(|ui| {
         if ui.button("Give Kit").clicked() {
             egui_actions.actions.push(EguiAction::ChatCommand {
-                cmd: ChatCommand::Kit,
+                cmd: ServerChatCommand::Kit,
                 args: vec![common::cmd::KITS[state.kits_selected_idx].clone()],
             });
         };
@@ -67,7 +67,7 @@ fn draw_give_items(ui: &mut Ui, state: &mut AdminCommandState, egui_actions: &mu
                 );
                 if ui.button("Give Items").clicked() {
                     egui_actions.actions.push(EguiAction::ChatCommand {
-                        cmd: ChatCommand::GiveItem,
+                        cmd: ServerChatCommand::GiveItem,
                         args: vec![
                             format!(
                                 "common.items.{}",
diff --git a/voxygen/egui/src/lib.rs b/voxygen/egui/src/lib.rs
index b7aaf52d6e..8f2b602c01 100644
--- a/voxygen/egui/src/lib.rs
+++ b/voxygen/egui/src/lib.rs
@@ -10,6 +10,7 @@ mod widgets;
 
 use client::{Client, Join, World, WorldExt};
 use common::{
+    cmd::ServerChatCommand,
     comp,
     comp::{inventory::item::armor::Friction, Poise, PoiseState},
 };
@@ -24,10 +25,7 @@ use crate::{
     admin::draw_admin_commands_window, character_states::draw_char_state_group,
     experimental_shaders::draw_experimental_shaders_window, widgets::two_col_row,
 };
-use common::{
-    cmd::ChatCommand,
-    comp::{aura::AuraKind::Buff, Body, Fluid},
-};
+use common::comp::{aura::AuraKind::Buff, Body, Fluid};
 use egui_winit_platform::Platform;
 use std::time::Duration;
 #[cfg(feature = "use-dyn-lib")]
@@ -131,7 +129,10 @@ pub enum EguiDebugShapeAction {
 }
 
 pub enum EguiAction {
-    ChatCommand { cmd: ChatCommand, args: Vec<String> },
+    ChatCommand {
+        cmd: ServerChatCommand,
+        args: Vec<String>,
+    },
     DebugShape(EguiDebugShapeAction),
     SetExperimentalShader(String, bool),
 }
@@ -613,13 +614,20 @@ fn selected_entity_window(
                                 .spacing([40.0, 4.0])
                                 .max_col_width(100.0)
                                 .striped(true)
-                                .show(ui, |ui| #[rustfmt::skip] {
+                                // Apparently, if the #[rustfmt::skip] is in front of the closure scope, rust-analyzer can't 
+                                // parse the code properly. Things will *sometimes* work if the skip is on the other side of
+                                // the opening bracket (even though that should only skip formatting the first line of the
+                                // closure), but things as arbitrary as adding a comment to the code cause it to be formatted
+                                // again. Thus, there is a completely pointless inner scope in this closure, just so that the
+                                // code doesn't take up an unreasonable amount of space when formatted. We need that space for
+                                // interesting and educational code comments like this one.
+                                .show(ui, |ui| { #[rustfmt::skip] {
                                     ui.label("State");
                                     poise_state_label(ui, poise);
                                     ui.end_row();
                                     two_col_row(ui, "Current", format!("{:.1}/{:.1}", poise.current(), poise.maximum()));
                                     two_col_row(ui, "Base Max", format!("{:.1}", poise.base_max()));
-                                });
+                                }});
                         });
                 }
 
diff --git a/voxygen/src/cmd.rs b/voxygen/src/cmd.rs
new file mode 100644
index 0000000000..96f422e3df
--- /dev/null
+++ b/voxygen/src/cmd.rs
@@ -0,0 +1,411 @@
+use std::str::FromStr;
+
+use crate::GlobalState;
+use client::Client;
+use common::{cmd::*, parse_cmd_args, uuid::Uuid};
+
+// Please keep this sorted alphabetically, same as with server commands :-)
+#[derive(Clone, Copy, strum::EnumIter)]
+pub enum ClientChatCommand {
+    Mute,
+    Unmute,
+}
+
+impl ClientChatCommand {
+    pub fn data(&self) -> ChatCommandData {
+        use ArgumentSpec::*;
+        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,
+            ),
+        }
+    }
+
+    pub fn keyword(&self) -> &'static str {
+        match self {
+            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::<Vec<_>>()
+            .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::SiteName(_) => "{/.*/}",
+                ArgumentSpec::Float(_, _, _) => "{}",
+                ArgumentSpec::Integer(_, _, _) => "{d}",
+                ArgumentSpec::Any(_, _) => "{}",
+                ArgumentSpec::Command(_) => "{}",
+                ArgumentSpec::Message(_) => "{/.*/}",
+                ArgumentSpec::SubCommand => "{} {/.*/}",
+                ArgumentSpec::Enum(_, _, _) => "{}",
+                ArgumentSpec::Boolean(_, _, _) => "{}",
+            })
+            .collect::<Vec<_>>()
+            .join(" ")
+    }
+
+    /// Produce an iterator over all the available commands
+    pub fn iter() -> impl Iterator<Item = Self> { <Self as strum::IntoEnumIterator>::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<Item = (&'static str, Self)> {
+        Self::iter().map(|c| (c.keyword(), c))
+    }
+}
+
+impl FromStr for ClientChatCommand {
+    type Err = ();
+
+    fn from_str(keyword: &str) -> Result<ClientChatCommand, ()> {
+        Self::iter()
+            .map(|c| (c.keyword(), c))
+            .find_map(|(kwd, command)| (kwd == keyword).then(|| command))
+            .ok_or(())
+    }
+}
+
+#[derive(Clone, Copy)]
+pub enum ChatCommandKind {
+    Client(ClientChatCommand),
+    Server(ServerChatCommand),
+}
+
+impl FromStr for ChatCommandKind {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, String> {
+        if let Ok(cmd) = s.parse::<ClientChatCommand>() {
+            Ok(ChatCommandKind::Client(cmd))
+        } else if let Ok(cmd) = s.parse::<ServerChatCommand>() {
+            Ok(ChatCommandKind::Server(cmd))
+        } else {
+            Err(format!("Could not find a command named {}.", s))
+        }
+    }
+}
+
+/// 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<Option<String>, String>;
+
+/// 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(
+    client: &mut Client,
+    global_state: &mut GlobalState,
+    cmd: &str,
+    args: Vec<String>,
+) -> CommandResult {
+    let command = ChatCommandKind::from_str(cmd)?;
+
+    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 run_client_command(
+    client: &mut Client,
+    global_state: &mut GlobalState,
+    command: ClientChatCommand,
+    args: Vec<String>,
+) -> Result<String, String> {
+    match command {
+        ClientChatCommand::Mute => handle_mute(client, global_state, args),
+        ClientChatCommand::Unmute => handle_unmute(client, global_state, args),
+    }
+}
+
+fn handle_mute(
+    client: &Client,
+    global_state: &mut GlobalState,
+    args: Vec<String>,
+) -> Result<String, String> {
+    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<String>,
+) -> Result<String, String> {
+    // 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())
+    }
+}
+
+/// A helper function to get the Uuid of a player with a given alias
+pub fn get_player_uuid(client: &Client, alias: &String) -> Option<Uuid> {
+    client
+        .player_list()
+        .values()
+        .find(|p| p.player_alias == *alias)
+        .map(|p| p.uuid)
+}
+
+trait TabComplete {
+    fn complete(&self, part: &str, client: &Client) -> Vec<String>;
+}
+
+impl TabComplete for ArgumentSpec {
+    fn complete(&self, part: &str, client: &Client) -> Vec<String> {
+        match self {
+            ArgumentSpec::PlayerName(_) => 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::Boolean(_, part, _) => vec!["true", "false"]
+                .iter()
+                .filter(|string| string.starts_with(part))
+                .map(|c| c.to_string())
+                .collect(),
+        }
+    }
+}
+
+fn complete_player(part: &str, client: &Client) -> Vec<String> {
+    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<String> {
+    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<usize> {
+    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<String> {
+    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<String> {
+    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();
+        let i = iter.count() + if word.is_empty() { 1 } else { 0 };
+        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::<ServerChatCommand>() {
+                Some(cmd.data().args)
+            } else if let Ok(cmd) = cmd.parse::<ClientChatCommand>() {
+                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<_>>();
+
+    // 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()
+    ]);
+}
diff --git a/voxygen/src/hud/chat.rs b/voxygen/src/hud/chat.rs
index db7ca32be0..e882705fe0 100644
--- a/voxygen/src/hud/chat.rs
+++ b/voxygen/src/hud/chat.rs
@@ -2,8 +2,8 @@ use super::{
     img_ids::Imgs, ChatTab, ERROR_COLOR, FACTION_COLOR, GROUP_COLOR, INFO_COLOR, KILL_COLOR,
     OFFLINE_COLOR, ONLINE_COLOR, REGION_COLOR, SAY_COLOR, TELL_COLOR, TEXT_COLOR, WORLD_COLOR,
 };
-use crate::{settings::chat::MAX_CHAT_TABS, ui::fonts::Fonts, GlobalState};
-use client::{cmd, Client};
+use crate::{cmd::complete, settings::chat::MAX_CHAT_TABS, ui::fonts::Fonts, GlobalState};
+use client::Client;
 use common::comp::{
     chat::{KillSource, KillType},
     group::Role,
@@ -108,7 +108,11 @@ impl<'a> Chat<'a> {
 
     pub fn prepare_tab_completion(mut self, input: String) -> Self {
         self.force_completions = if let Some(index) = input.find('\t') {
-            Some(cmd::complete(&input[..index], self.client))
+            Some(complete(
+                &input[..index],
+                self.client,
+                self.global_state.settings.chat.chat_cmd_prefix,
+            ))
         } else {
             None
         };
@@ -659,7 +663,7 @@ impl<'a> Widget for Chat<'a> {
                     s.history.truncate(self.history_max);
                 }
             });
-            if let Some(msg) = msg.strip_prefix('/') {
+            if let Some(msg) = msg.strip_prefix(chat_settings.chat_cmd_prefix) {
                 match parse_cmd(msg) {
                     Ok((name, args)) => events.push(Event::SendCommand(name, args)),
                     Err(err) => self.new_messages.push_back(ChatMsg {
diff --git a/voxygen/src/hud/mod.rs b/voxygen/src/hud/mod.rs
index 9263f24aa1..da21b73feb 100644
--- a/voxygen/src/hud/mod.rs
+++ b/voxygen/src/hud/mod.rs
@@ -53,6 +53,7 @@ use social::Social;
 use trade::Trade;
 
 use crate::{
+    cmd::get_player_uuid,
     ecs::{comp as vcomp, comp::HpFloaterList},
     game_input::GameInput,
     hud::{img_ids::ImgsRot, prompt_dialog::DialogOutcomeEvent},
@@ -1789,6 +1790,21 @@ impl Hud {
             self.speech_bubbles
                 .retain(|_uid, bubble| bubble.timeout > now);
 
+            // Don't show messages from muted players
+            self.new_messages.retain(|msg| match msg.uid() {
+                Some(uid) => match client.player_list().get(&uid) {
+                    Some(player_info) => {
+                        if let Some(uuid) = get_player_uuid(client, &player_info.player_alias) {
+                            !global_state.profile.mutelist.contains_key(&uuid)
+                        } else {
+                            true
+                        }
+                    },
+                    None => true,
+                },
+                None => true,
+            });
+
             // Push speech bubbles
             for msg in self.new_messages.iter() {
                 if let Some((bubble, uid)) = msg.to_bubble() {
diff --git a/voxygen/src/lib.rs b/voxygen/src/lib.rs
index d326b6c5c7..1411a128b8 100644
--- a/voxygen/src/lib.rs
+++ b/voxygen/src/lib.rs
@@ -18,6 +18,7 @@
 #[macro_use]
 pub mod ui;
 pub mod audio;
+pub mod cmd;
 pub mod controller;
 mod credits;
 mod ecs;
diff --git a/voxygen/src/profile.rs b/voxygen/src/profile.rs
index 39b8e0d214..4230883706 100644
--- a/voxygen/src/profile.rs
+++ b/voxygen/src/profile.rs
@@ -1,5 +1,5 @@
 use crate::hud;
-use common::character::CharacterId;
+use common::{character::CharacterId, uuid::Uuid};
 use hashbrown::HashMap;
 use serde::{Deserialize, Serialize};
 use std::{
@@ -57,6 +57,7 @@ impl Default for ServerProfile {
 #[serde(default)]
 pub struct Profile {
     pub servers: HashMap<String, ServerProfile>,
+    pub mutelist: HashMap<Uuid, String>,
     /// Temporary character profile, used when it should
     /// not be persisted to the disk.
     #[serde(skip)]
diff --git a/voxygen/src/session/mod.rs b/voxygen/src/session/mod.rs
index 9adc7ca66f..65a568fe0b 100644
--- a/voxygen/src/session/mod.rs
+++ b/voxygen/src/session/mod.rs
@@ -41,6 +41,7 @@ use common_net::{
 
 use crate::{
     audio::sfx::SfxEvent,
+    cmd::run_command,
     error::Error,
     game_input::GameInput,
     hud::{
@@ -1155,7 +1156,16 @@ impl PlayState for SessionState {
                         self.client.borrow_mut().send_chat(msg);
                     },
                     HudEvent::SendCommand(name, args) => {
-                        self.client.borrow_mut().send_command(name, args);
+                        match run_command(&mut self.client.borrow_mut(), global_state, &name, args)
+                        {
+                            Ok(Some(info)) => {
+                                self.hud.new_message(ChatType::CommandInfo.chat_msg(&info))
+                            },
+                            Ok(None) => {}, // Server will provide an info message
+                            Err(error) => {
+                                self.hud.new_message(ChatType::CommandError.chat_msg(error))
+                            },
+                        };
                     },
                     HudEvent::CharacterSelection => {
                         self.client.borrow_mut().request_remove_character()
diff --git a/voxygen/src/settings/chat.rs b/voxygen/src/settings/chat.rs
index ae16c71190..6bca1a55c5 100644
--- a/voxygen/src/settings/chat.rs
+++ b/voxygen/src/settings/chat.rs
@@ -73,6 +73,7 @@ pub struct ChatSettings {
     pub chat_character_name: bool,
     pub chat_tabs: Vec<ChatTab>,
     pub chat_tab_index: Option<usize>,
+    pub chat_cmd_prefix: char,
 }
 
 impl Default for ChatSettings {
@@ -82,6 +83,7 @@ impl Default for ChatSettings {
             chat_character_name: true,
             chat_tabs: vec![ChatTab::default()],
             chat_tab_index: Some(0),
+            chat_cmd_prefix: '/',
         }
     }
 }