//! Server commands, typed into the server console or sent by players as //! `/command` chat messages. use bevy::prelude::*; use bevy_replicon::prelude::*; use game_core::{components::Character, protocol::GameLog}; use crate::{ config, persistence, players::{self, Controller, PlayerId, Players, RejectedLogins}, }; /// Who issued a command. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CommandSource { /// The dedicated server's terminal. Console, Player { client: ClientId, admin: bool, /// Host of the embedded server; may also stop it. owner: bool, }, } impl CommandSource { fn is_admin(self) -> bool { match self { Self::Console => true, Self::Player { admin, owner, .. } => admin || owner, } } fn may_stop(self) -> bool { match self { Self::Console => true, Self::Player { owner, .. } => owner, } } } #[derive(Clone, Copy, PartialEq, Eq)] enum Permission { Everyone, Admin, Stop, } /// Usage, description and who may run it. const COMMANDS: &[(&str, &str, Permission)] = &[ ("help", "list commands", Permission::Everyone), ("players", "list online players", Permission::Everyone), ("say MESSAGE", "message all players", Permission::Admin), ( "kick PLAYER [REASON]", "disconnect a player", Permission::Admin, ), ( "ban PLAYER", "ban a player by name or id", Permission::Admin, ), ("unban PLAYER", "lift a ban", Permission::Admin), ("bans", "list banned players", Permission::Admin), ( "whitelist on|off|list", "toggle or show the whitelist", Permission::Admin, ), ( "whitelist add|remove PLAYER", "edit the whitelist", Permission::Admin, ), ( "admin add|remove PLAYER", "grant or revoke admin rights", Permission::Admin, ), ("admin list", "list admins", Permission::Admin), ("rejected", "recently rejected logins", Permission::Admin), ("save", "save the world now", Permission::Admin), ( "config get KEY", "show a setting, e.g. `config get access.whitelist`", Permission::Admin, ), ( "config set KEY VALUE", "change a setting and write it to the config file", Permission::Admin, ), ("stop", "save and shut down the server", Permission::Stop), ]; /// Usage and description of the commands available to `source`. pub fn help(source: CommandSource) -> Vec<(&'static str, &'static str)> { COMMANDS .iter() .filter(|(_, _, p)| allowed(source, *p)) .map(|(usage, what, _)| (*usage, *what)) .collect() } fn allowed(source: CommandSource, permission: Permission) -> bool { match permission { Permission::Everyone => true, Permission::Admin => source.is_admin(), Permission::Stop => source.may_stop(), } } fn permission(command: &str) -> Option { COMMANDS .iter() .find(|(usage, _, _)| usage.split(' ').next() == Some(command)) .map(|(_, _, p)| *p) } /// Runs a command line and returns the reply. pub fn execute(world: &mut World, source: CommandSource, line: &str) -> Vec { let line = line.trim().trim_start_matches('/'); let (command, rest) = line.split_once(' ').unwrap_or((line, "")); let rest = rest.trim(); if command.is_empty() { return vec![]; } match permission(command) { None => { return vec![format!( "Unknown command `{command}`. Type `help` for a list." )]; } Some(p) if !allowed(source, p) => { return vec![format!("You are not allowed to use `{command}`.")]; } Some(_) => {} } let actor = actor_name(world, source); match command { "help" => help(source) .into_iter() .map(|(usage, what)| format!("{usage:<28} {what}")) .collect(), "players" => { let mut q = world.query::<(&Character, &PlayerId, Has)>(); let mut names: Vec = q .iter(world) .map(|(c, id, admin)| { let mut entry = c.name.clone(); if admin { entry.push_str(" [admin]"); } if source.is_admin() { entry.push_str(&format!(" ({})", id.0)); } entry }) .collect(); names.sort(); let mut reply = vec![format!("{} online", names.len())]; reply.extend(names); reply } "say" if !rest.is_empty() => { let text = format!("[{actor}] {rest}"); world.commands().server_trigger(ToClients { targets: SendTargets::All, message: GameLog { text: text.clone() }, }); world.flush(); vec![text] } "kick" if !rest.is_empty() => { let (target, reason) = rest.split_once(' ').unwrap_or((rest, "")); let reason = reason.trim(); kick(world, &actor, target, reason) } "ban" if !rest.is_empty() => { let target = resolve(world, rest); match list_edit(world, "access.banned", &target.entry, true) { Ok(true) => { let msg = format!("{actor} banned {}.", target.label); notify(world, &msg); vec![msg] } Ok(false) => vec![format!("{} is already banned.", target.label)], Err(e) => vec![e], } } "unban" if !rest.is_empty() => unlist(world, &actor, "access.banned", rest, "banned"), "bans" => show_list(world, |c| &c.access.banned, "Banned"), "whitelist" => whitelist_command(world, &actor, rest), "admin" => admin_command(world, &actor, rest), "rejected" => { let log = &world.resource::().0; if log.is_empty() { vec!["No rejected logins.".into()] } else { log.iter().cloned().collect() } } "save" => { if world.contains_resource::() { persistence::save_world_blocking(world); vec!["World saved.".into()] } else { vec!["Saving is disabled.".into()] } } "config" => config_command(world, rest), "stop" => { info!("{actor} stopped the server"); world.write_message(AppExit::Success); vec!["Shutting down.".into()] } _ => { let usage: Vec = COMMANDS .iter() .filter(|(usage, _, _)| usage.split(' ').next() == Some(command)) .map(|(usage, _, _)| format!("Usage: {usage}")) .collect(); usage } } } fn actor_name(world: &mut World, source: CommandSource) -> String { match source { CommandSource::Console => "server".into(), CommandSource::Player { client, .. } => world .resource::() .by_client .get(&client) .and_then(|&e| world.get::(e)) .map_or_else(|| "server".into(), |c| c.name.clone()), } } /// A player named in a command. struct Target { /// Value stored in access lists: the player id if known, otherwise the given name. entry: String, /// For messages. label: String, } /// Resolves a character name or player id. fn resolve(world: &mut World, input: &str) -> Target { let input = input.trim(); if uuid::Uuid::parse_str(input).is_ok() { let id = input.to_lowercase(); let label = match players::character_name(world, &id) { Some(name) => format!("{name} ({id})"), None => id.clone(), }; return Target { entry: id, label }; } match players::find_player_by_name(world, input) { Some(id) => { let name = players::character_name(world, &id).unwrap_or_else(|| input.to_owned()); Target { label: format!("{name} ({id})"), entry: id, } } // Unknown players are listed by name, so they can be banned or // whitelisted before they ever join. None => Target { entry: input.to_owned(), label: input.to_owned(), }, } } /// Messages online admins and the server log. fn notify(world: &mut World, text: &str) { info!("{text}"); players::tell_admins(world, &format!("[admin] {text}")); } fn kick(world: &mut World, actor: &str, target: &str, reason: &str) -> Vec { let target = resolve(world, target); let mut q = world.query::<(&PlayerId, &Controller)>(); let Some(client) = q .iter(world) .find(|(id, _)| id.0 == target.entry) .map(|(_, c)| c.0) else { return vec![format!("{} is not online.", target.label)]; }; let text = if reason.is_empty() { "You were kicked by an admin.".to_owned() } else { format!("You were kicked: {reason}") }; players::disconnect(&mut world.commands(), client, text); world.flush(); let msg = format!("{actor} kicked {}.", target.label); notify(world, &msg); vec![msg] } /// Adds (`add`) or removes a value from a string list setting. Returns whether it changed. fn list_edit(world: &mut World, key: &str, value: &str, add: bool) -> Result { let config = world.resource::(); let mut list = match key { "access.banned" => config.access.banned.clone(), "access.whitelist" => config.access.whitelist.clone(), "access.admins" => config.access.admins.clone(), _ => unreachable!("not a list setting: {key}"), }; let before = list.len(); if add { if list.iter().any(|e| e.eq_ignore_ascii_case(value)) { return Ok(false); } list.push(value.to_owned()); } else { list.retain(|e| !e.eq_ignore_ascii_case(value)); if list.len() == before { return Ok(false); } } let toml = toml_edit::Value::from_iter(list.iter().map(String::as_str)).to_string(); config::set_runtime(world, key, toml.trim()) .map(|_| true) .map_err(|e| e.to_string()) } /// Removes a player from a list by id or by name. fn unlist(world: &mut World, actor: &str, key: &str, input: &str, what: &str) -> Vec { let target = resolve(world, input); let name = players::character_name(world, &target.entry); let mut changed = false; for value in std::iter::once(target.entry.as_str()) .chain(name.as_deref()) .chain(std::iter::once(input.trim())) { match list_edit(world, key, value, false) { Ok(c) => changed |= c, Err(e) => return vec![e], } } if changed { let msg = format!("{actor}: {} is no longer {what}.", target.label); notify(world, &msg); vec![msg] } else { vec![format!("{} is not {what}.", target.label)] } } fn show_list( world: &mut World, list: impl Fn(&config::ServerConfig) -> &Vec, title: &str, ) -> Vec { let entries = list(world.resource::()).clone(); if entries.is_empty() { return vec![format!("{title}: none")]; } let mut reply = vec![format!("{title}:")]; for entry in entries { match players::character_name(world, &entry) { Some(name) => reply.push(format!(" {name} ({entry})")), None => reply.push(format!(" {entry}")), } } reply } fn whitelist_command(world: &mut World, actor: &str, args: &str) -> Vec { let (action, target) = args.split_once(' ').unwrap_or((args, "")); let target = target.trim(); match (action, target.is_empty()) { ("on" | "off", true) => { let on = action == "on"; match config::set_runtime(world, "access.whitelist_enabled", &on.to_string()) { Ok(_) => { let msg = format!( "{actor} turned the whitelist {}.", if on { "on" } else { "off" } ); notify(world, &msg); vec![msg] } Err(e) => vec![e.to_string()], } } ("list", true) => { let enabled = world .resource::() .access .whitelist_enabled; let title = format!("Whitelist ({})", if enabled { "on" } else { "off" }); show_list(world, |c| &c.access.whitelist, &title) } ("add", false) => { let target = resolve(world, target); match list_edit(world, "access.whitelist", &target.entry, true) { Ok(true) => { let msg = format!("{actor} whitelisted {}.", target.label); notify(world, &msg); vec![msg] } Ok(false) => vec![format!("{} is already whitelisted.", target.label)], Err(e) => vec![e], } } ("remove", false) => unlist(world, actor, "access.whitelist", target, "whitelisted"), _ => vec!["Usage: whitelist on|off|list | whitelist add|remove PLAYER".into()], } } fn admin_command(world: &mut World, actor: &str, args: &str) -> Vec { let (action, target) = args.split_once(' ').unwrap_or((args, "")); let target = target.trim(); match (action, target.is_empty()) { ("list", true) => show_list(world, |c| &c.access.admins, "Admins"), ("add", false) => { let target = resolve(world, target); // Admin rights are bound to player ids, since names can be taken // by anyone before the character exists. if uuid::Uuid::parse_str(&target.entry).is_err() { return vec![format!( "Unknown player `{}`. Use a character name of this world or a player id.", target.label )]; } match list_edit(world, "access.admins", &target.entry, true) { Ok(true) => { let msg = format!("{actor} made {} an admin.", target.label); notify(world, &msg); vec![msg] } Ok(false) => vec![format!("{} is already an admin.", target.label)], Err(e) => vec![e], } } ("remove", false) => unlist(world, actor, "access.admins", target, "an admin"), _ => vec!["Usage: admin list | admin add|remove PLAYER".into()], } } fn config_command(world: &mut World, args: &str) -> Vec { let mut parts = args.splitn(3, ' '); match (parts.next(), parts.next(), parts.next()) { (Some("get"), Some(key), None) => match world.resource::().get(key) { Ok(value) => vec![format!("{key} = {value}")], Err(e) => vec![e.to_string()], }, (Some("set"), Some(key), Some(value)) => match config::set_runtime(world, key, value) { Ok(note) => { let value = world .resource::() .get(key) .unwrap_or_default(); let mut reply = format!("{key} = {value}"); if let Some(note) = note { reply.push_str(&format!(" ({note})")); } vec![reply] } Err(e) => vec![e.to_string()], }, _ => vec!["Usage: config get KEY | config set KEY VALUE".into()], } }