diff --git a/CHANGELOG.md b/CHANGELOG.md index 506c61a84a..4a7f2c5520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Crafting recipe for Cloverleaf glider. - Burning Potion that applies the Burning effect to the user - Precision +- A few new commands, `/tether`, `/destroy_tethers`, `/mount` and `/dismount`. +- A way to target non-player entities with commands. With rtsim_id: `rtsim@`, with uid: `uid@`. +- Shorthand in voxygen for specific entities in commands, some examples `@target`, `@mount`, `@viewpoint`. ### Changed diff --git a/assets/voxygen/i18n/en/command.ftl b/assets/voxygen/i18n/en/command.ftl index 4f807e121b..cbc6007b08 100644 --- a/assets/voxygen/i18n/en/command.ftl +++ b/assets/voxygen/i18n/en/command.ftl @@ -91,4 +91,8 @@ command-unimplemented-teleporter-spawn = Teleporter spawning is not implemented command-kit-inventory-unavailable = Could not get inventory command-inventory-cant-fit-item = Can't fit item to inventory # Emitted by /disconnect_all when you dont exist (?) -command-you-dont-exist = You do not exist, so you cannot use this command \ No newline at end of file +command-you-dont-exist = You do not exist, so you cannot use this command +command-destroyed-tethers = All tethers destroyed! You are now free +command-destroyed-no-tethers = You're not connected to any tethers +command-dismounted = Dismounted +command-no-dismount = You're not riding or being ridden \ No newline at end of file diff --git a/common/src/cmd.rs b/common/src/cmd.rs index 8a4093c0c5..1d84f1ed5f 100644 --- a/common/src/cmd.rs +++ b/common/src/cmd.rs @@ -263,6 +263,39 @@ lazy_static! { }; } +pub enum EntityTarget { + Player(String), + RtsimNpc(u64), + Uid(crate::uid::Uid), +} + +impl FromStr for EntityTarget { + type Err = String; + + fn from_str(s: &str) -> Result { + // NOTE: `@` is an invalid character in usernames, so we can use it here. + if let Some((spec, data)) = s.split_once('@') { + match spec { + "rtsim" => Ok(EntityTarget::RtsimNpc(u64::from_str(data).map_err( + |_| format!("Expected a valid number after 'rtsim@' but found {data}."), + )?)), + "uid" => Ok(EntityTarget::Uid( + u64::from_str(data) + .map_err(|_| { + format!("Expected a valid number after 'uid@' but found {data}.") + })? + .into(), + )), + _ => Err(format!( + "Expected either 'rtsim' or 'uid' before '@' but found '{spec}'" + )), + } + } else { + Ok(EntityTarget::Player(s.to_string())) + } + } +} + // Please keep this sorted alphabetically :-) #[derive(Copy, Clone, strum::EnumIter)] pub enum ServerChatCommand { @@ -283,7 +316,9 @@ pub enum ServerChatCommand { DebugColumn, DebugWays, DeleteLocation, + DestroyTethers, DisconnectAllPlayers, + Dismount, DropAll, Dummy, Explosion, @@ -312,6 +347,7 @@ pub enum ServerChatCommand { MakeSprite, MakeVolume, Motd, + Mount, Object, PermitBuild, Players, @@ -340,6 +376,7 @@ pub enum ServerChatCommand { Spawn, Sudo, Tell, + Tether, Time, TimeScale, Tp, @@ -739,8 +776,8 @@ impl ServerChatCommand { Some(Admin), ), ServerChatCommand::Sudo => cmd( - vec![PlayerName(Required), SubCommand], - "Run command as if you were another player", + vec![EntityTarget(Required), SubCommand], + "Run command as if you were another entity", Some(Moderator), ), ServerChatCommand::Tell => cmd( @@ -760,10 +797,10 @@ impl ServerChatCommand { ), ServerChatCommand::Tp => cmd( vec![ - PlayerName(Optional), + EntityTarget(Optional), Boolean("Dismount from ship", "true".to_string(), Optional), ], - "Teleport to another player", + "Teleport to another entity", Some(Moderator), ), ServerChatCommand::RtsimTp => cmd( @@ -862,6 +899,25 @@ impl ServerChatCommand { ServerChatCommand::RepairEquipment => { cmd(vec![], "Repairs all equipped items", Some(Admin)) }, + ServerChatCommand::Tether => cmd( + vec![ + EntityTarget(Required), + Boolean("automatic length", "true".to_string(), Optional), + ], + "Tether another entity to yourself", + Some(Admin), + ), + ServerChatCommand::DestroyTethers => { + cmd(vec![], "Destroy all tethers connected to you", Some(Admin)) + }, + ServerChatCommand::Mount => { + cmd(vec![EntityTarget(Required)], "Mount an entity", Some(Admin)) + }, + ServerChatCommand::Dismount => cmd( + vec![EntityTarget(Required)], + "Dismount if you are riding, or dismount anything riding you", + Some(Admin), + ), } } @@ -952,6 +1008,10 @@ impl ServerChatCommand { ServerChatCommand::Lightning => "lightning", ServerChatCommand::Scale => "scale", ServerChatCommand::RepairEquipment => "repair_equipment", + ServerChatCommand::Tether => "tether", + ServerChatCommand::DestroyTethers => "destroy_tethers", + ServerChatCommand::Mount => "mount", + ServerChatCommand::Dismount => "dismount", } } @@ -1001,6 +1061,7 @@ impl ServerChatCommand { .iter() .map(|arg| match arg { ArgumentSpec::PlayerName(_) => "{}", + ArgumentSpec::EntityTarget(_) => "{}", ArgumentSpec::SiteName(_) => "{/.*/}", ArgumentSpec::Float(_, _, _) => "{}", ArgumentSpec::Integer(_, _, _) => "{d}", @@ -1037,7 +1098,7 @@ impl FromStr for ServerChatCommand { } } -#[derive(Eq, PartialEq, Debug)] +#[derive(Eq, PartialEq, Debug, Clone, Copy)] pub enum Requirement { Required, Optional, @@ -1047,6 +1108,8 @@ pub enum Requirement { pub enum ArgumentSpec { /// The argument refers to a player by alias PlayerName(Requirement), + /// The arguments refers to an entity in some way. + EntityTarget(Requirement), // The argument refers to a site, by name. SiteName(Requirement), /// The argument is a float. The associated values are @@ -1090,6 +1153,13 @@ impl ArgumentSpec { "[player]".to_string() } }, + ArgumentSpec::EntityTarget(req) => { + if &Requirement::Required == req { + "".to_string() + } else { + "[entity]".to_string() + } + }, ArgumentSpec::SiteName(req) => { if &Requirement::Required == req { "".to_string() @@ -1149,6 +1219,22 @@ impl ArgumentSpec { }, } } + + pub fn requirement(&self) -> Requirement { + match self { + ArgumentSpec::PlayerName(r) + | ArgumentSpec::EntityTarget(r) + | ArgumentSpec::SiteName(r) + | ArgumentSpec::Float(_, _, r) + | ArgumentSpec::Integer(_, _, r) + | ArgumentSpec::Any(_, r) + | ArgumentSpec::Command(r) + | ArgumentSpec::Message(r) + | ArgumentSpec::Enum(_, _, r) + | ArgumentSpec::Boolean(_, _, r) => *r, + ArgumentSpec::SubCommand => Requirement::Required, + } + } } /// Parse a series of command arguments into values, including collecting all diff --git a/common/src/mounting.rs b/common/src/mounting.rs index 94e90a4c6b..79b666b0d3 100644 --- a/common/src/mounting.rs +++ b/common/src/mounting.rs @@ -279,6 +279,14 @@ pub struct VolumeRiders { riders: HashSet>, } +impl VolumeRiders { + pub fn clear(&mut self) -> bool { + let res = !self.riders.is_empty(); + self.riders.clear(); + res + } +} + impl Component for VolumeRiders { type Storage = DenseVecStorage; } diff --git a/server/src/cmd.rs b/server/src/cmd.rs index 678ce6608d..664e85acdb 100644 --- a/server/src/cmd.rs +++ b/server/src/cmd.rs @@ -21,7 +21,7 @@ use common::{ assets, calendar::Calendar, cmd::{ - AreaKind, KitSpec, ServerChatCommand, BUFF_PACK, BUFF_PARSER, ITEM_SPECS, + AreaKind, EntityTarget, KitSpec, ServerChatCommand, BUFF_PACK, BUFF_PARSER, ITEM_SPECS, KIT_MANIFEST_PATH, PRESET_MANIFEST_PATH, }, comp::{ @@ -208,6 +208,10 @@ fn do_command( ServerChatCommand::Lightning => handle_lightning, ServerChatCommand::Scale => handle_scale, ServerChatCommand::RepairEquipment => handle_repair_equipment, + ServerChatCommand::Tether => handle_tether, + ServerChatCommand::DestroyTethers => handle_destroy_tethers, + ServerChatCommand::Mount => handle_mount, + ServerChatCommand::Dismount => handle_dismount, }; handler(server, client, target, args, cmd) @@ -1288,9 +1292,9 @@ fn handle_tp( args: Vec, action: &ServerChatCommand, ) -> CmdResult<()> { - let (player, dismount_volume) = parse_cmd_args!(args, String, bool); - let player = if let Some(alias) = player { - find_alias(server.state.ecs(), &alias)?.0 + let (entity_target, dismount_volume) = parse_cmd_args!(args, EntityTarget, bool); + let player = if let Some(entity_target) = entity_target { + get_entity_target(entity_target, server)? } else if client != target { client } else { @@ -1582,6 +1586,17 @@ fn handle_spawn( ) => { let uid = uid(server, target, "target")?; let alignment = parse_alignment(uid, &opt_align)?; + + if matches!(alignment, Alignment::Owned(_)) + && server + .state + .ecs() + .read_storage::() + .contains(target) + { + return Err("Spawning this pet would create an anchor chain".into()); + } + let amount = opt_amount.filter(|x| *x > 0).unwrap_or(1).min(50); let ai = opt_ai.unwrap_or(true); @@ -3527,10 +3542,12 @@ fn handle_skill_point( args: Vec, action: &ServerChatCommand, ) -> CmdResult<()> { - if let (Some(a_skill_tree), Some(sp), a_alias) = parse_cmd_args!(args, String, u16, String) { + if let (Some(a_skill_tree), Some(sp), entity_target) = + parse_cmd_args!(args, String, u16, EntityTarget) + { let skill_tree = parse_skill_tree(&a_skill_tree)?; - let player = a_alias - .map(|alias| find_alias(server.state.ecs(), &alias).map(|(target, _)| target)) + let player = entity_target + .map(|entity_target| get_entity_target(entity_target, server)) .unwrap_or(Ok(target))?; if let Some(mut skill_set) = server @@ -3542,7 +3559,7 @@ fn handle_skill_point( skill_set.add_skill_points(skill_tree, sp); Ok(()) } else { - Err("Player has no stats!".into()) + Err("Entity has no stats!".into()) } } else { Err(Content::Plain(action.help_string())) @@ -3629,6 +3646,37 @@ fn handle_remove_lights( Ok(()) } +fn get_entity_target(entity_target: EntityTarget, server: &Server) -> CmdResult { + match entity_target { + EntityTarget::Player(alias) => Ok(find_alias(server.state.ecs(), &alias)?.0), + EntityTarget::RtsimNpc(id) => { + let (npc_id, _) = server + .state + .ecs() + .read_resource::() + .state() + .data() + .npcs + .iter() + .find(|(_, npc)| npc.uid == id) + .ok_or(Content::Plain(format!( + "Could not find rtsim npc with id {id}." + )))?; + server + .state() + .ecs() + .read_resource::() + .rtsim_entity(common::rtsim::RtSimEntity(npc_id)) + .ok_or(Content::Plain(format!("Npc with id {id} isn't loaded."))) + }, + EntityTarget::Uid(uid) => server + .state + .ecs() + .entity_from_uid(uid) + .ok_or(Content::Plain(format!("{uid:?} not found."))), + } +} + fn handle_sudo( server: &mut Server, client: EcsEntity, @@ -3636,23 +3684,34 @@ fn handle_sudo( args: Vec, action: &ServerChatCommand, ) -> CmdResult<()> { - if let (Some(player_alias), Some(cmd), cmd_args) = - parse_cmd_args!(args, String, String, ..Vec) + if let (Some(entity_target), Some(cmd), cmd_args) = + parse_cmd_args!(args, EntityTarget, String, ..Vec) { if let Ok(action) = cmd.parse() { - let (player, player_uuid) = find_alias(server.state.ecs(), &player_alias)?; + let entity = get_entity_target(entity_target, server)?; let client_uuid = uuid(server, client, "client")?; - verify_above_role( - server, - (client, client_uuid), - (player, player_uuid), - "Cannot sudo players with roles higher than your own.", - )?; + + // If the entity target is a player check if client has authority to sudo it. + { + let players = server.state.ecs().read_storage::(); + if let Some(player) = players.get(entity) { + let player_uuid = player.uuid(); + drop(players); + verify_above_role( + server, + (client, client_uuid), + (entity, player_uuid), + "Cannot sudo players with roles higher than your own.", + )?; + } else if server.entity_admin_role(client) < Some(AdminRole::Admin) { + return Err("You don't have permission to sudo non-players.".into()); + } + } // TODO: consider making this into a tail call or loop (to avoid the potential // stack overflow, although it's less of a risk coming from only mods and // admins). - do_command(server, client, player, cmd_args, &action) + do_command(server, client, entity, cmd_args, &action) } else { Err(Content::localized("command-unknown")) } @@ -4497,3 +4556,169 @@ fn handle_repair_equipment( Err(Content::Plain(action.help_string())) } } + +fn handle_tether( + server: &mut Server, + _client: EcsEntity, + target: EcsEntity, + args: Vec, + action: &ServerChatCommand, +) -> CmdResult<()> { + enum Either { + Left(A), + Right(B), + } + + impl FromStr for Either { + type Err = B::Err; + + fn from_str(s: &str) -> Result { + A::from_str(s) + .map(Either::Left) + .or_else(|_| B::from_str(s).map(Either::Right)) + } + } + if let (Some(entity_target), length) = parse_cmd_args!(args, EntityTarget, Either) { + let entity_target = get_entity_target(entity_target, server)?; + + let tether_leader = server.state.ecs().uid_from_entity(target); + let tether_follower = server.state.ecs().uid_from_entity(entity_target); + + if let (Some(leader), Some(follower)) = (tether_leader, tether_follower) { + let base_len = server + .state + .read_component_cloned::(target) + .map(|b| b.dimensions().y * 1.5 + 1.0) + .unwrap_or(6.0); + let tether_length = match length { + Some(Either::Left(l)) => l.max(0.0) + base_len, + Some(Either::Right(true)) => { + let leader_pos = position(server, target, "leader")?; + let follower_pos = position(server, entity_target, "follower")?; + + leader_pos.0.distance(follower_pos.0) + base_len + }, + _ => base_len, + }; + server + .state + .link(Tethered { + leader, + follower, + tether_length, + }) + .map_err(|_| "Failed to tether entities".into()) + } else { + Err("Tether members don't have Uids.".into()) + } + } else { + Err(Content::Plain(action.help_string())) + } +} + +fn handle_destroy_tethers( + server: &mut Server, + client: EcsEntity, + target: EcsEntity, + _args: Vec, + _action: &ServerChatCommand, +) -> CmdResult<()> { + let mut destroyed = false; + destroyed |= server + .state + .ecs() + .write_storage::>() + .remove(target) + .is_some(); + destroyed |= server + .state + .ecs() + .write_storage::>() + .remove(target) + .is_some(); + if destroyed { + server.notify_client( + client, + ServerGeneral::server_msg( + ChatType::CommandInfo, + Content::localized("command-destroyed-tethers"), + ), + ); + Ok(()) + } else { + Err(Content::localized("command-destroyed-no-tethers")) + } +} + +fn handle_mount( + server: &mut Server, + _client: EcsEntity, + target: EcsEntity, + args: Vec, + action: &ServerChatCommand, +) -> CmdResult<()> { + if let Some(entity_target) = parse_cmd_args!(args, EntityTarget) { + let entity_target = get_entity_target(entity_target, server)?; + + let rider = server.state.ecs().uid_from_entity(target); + let mount = server.state.ecs().uid_from_entity(entity_target); + + if let (Some(rider), Some(mount)) = (rider, mount) { + server + .state + .link(common::mounting::Mounting { mount, rider }) + .map_err(|_| "Failed to mount entities".into()) + } else { + Err("Mount and/or rider doesn't have an Uid component.".into()) + } + } else { + Err(Content::Plain(action.help_string())) + } +} + +fn handle_dismount( + server: &mut Server, + client: EcsEntity, + target: EcsEntity, + _args: Vec, + _action: &ServerChatCommand, +) -> CmdResult<()> { + let mut destroyed = false; + destroyed |= server + .state + .ecs() + .write_storage::>() + .remove(target) + .is_some(); + destroyed |= server + .state + .ecs() + .write_storage::>() + .remove(target) + .is_some(); + destroyed |= server + .state + .ecs() + .write_storage::>() + .remove(target) + .is_some(); + destroyed |= server + .state + .ecs() + .write_storage::() + .get_mut(target) + .map_or(false, |volume_riders| volume_riders.clear()); + + if destroyed { + server.notify_client( + client, + ServerGeneral::server_msg( + ChatType::CommandInfo, + Content::localized("command-dismounted"), + ), + ); + Ok(()) + } else { + Err(Content::localized("command-no-dismount")) + } +} diff --git a/server/src/state_ext.rs b/server/src/state_ext.rs index fd6813230a..0a4734247c 100644 --- a/server/src/state_ext.rs +++ b/server/src/state_ext.rs @@ -31,6 +31,7 @@ use common::{ resources::{Secs, Time, TimeOfDay}, rtsim::{Actor, RtSimEntity}, slowjob::SlowJobPool, + tether::Tethered, uid::{IdMaps, Uid}, util::Dir, LoadoutBuilder, ViewDistances, @@ -1164,6 +1165,7 @@ impl StateExt for State { maintain_link::(self); maintain_link::(self); + maintain_link::(self); } fn delete_entity_recorded( diff --git a/voxygen/src/cmd.rs b/voxygen/src/cmd.rs index 7b052ed9dc..e5b0e485fc 100644 --- a/voxygen/src/cmd.rs +++ b/voxygen/src/cmd.rs @@ -1,12 +1,25 @@ use std::str::FromStr; use crate::{ - render::ExperimentalShader, session::settings_change::change_render_mode, GlobalState, + render::ExperimentalShader, + session::{settings_change::change_render_mode, SessionState}, + GlobalState, }; use client::Client; -use common::{cmd::*, comp::Admin, parse_cmd_args, uuid::Uuid}; +use common::{ + cmd::*, + comp::Admin, + link::Is, + mounting::{Mount, Rider, VolumeRider}, + parse_cmd_args, + resources::PlayerEntity, + uid::Uid, + uuid::Uuid, +}; +use common_net::sync::WorldSyncExt; use levenshtein::levenshtein; -use strum::IntoEnumIterator; +use specs::{Join, WorldExt}; +use strum::{EnumIter, IntoEnumIterator}; // Please keep this sorted alphabetically, same as with server commands :-) #[derive(Clone, Copy, strum::EnumIter)] @@ -78,6 +91,7 @@ impl ClientChatCommand { .iter() .map(|arg| match arg { ArgumentSpec::PlayerName(_) => "{}", + ArgumentSpec::EntityTarget(_) => "{}", ArgumentSpec::SiteName(_) => "{/.*/}", ArgumentSpec::Float(_, _, _) => "{}", ArgumentSpec::Integer(_, _, _) => "{d}", @@ -142,27 +156,155 @@ impl FromStr for ChatCommandKind { /// text color type CommandResult = Result, String>; +#[derive(EnumIter)] +enum ClientEntityTarget { + Target, + Selected, + Viewpoint, + Mount, + Rider, + TargetSelf, +} + +impl ClientEntityTarget { + const PREFIX: char = '@'; + + fn keyword(&self) -> &'static str { + match self { + ClientEntityTarget::Target => "target", + ClientEntityTarget::Selected => "selected", + ClientEntityTarget::Viewpoint => "viewpoint", + ClientEntityTarget::Mount => "mount", + ClientEntityTarget::Rider => "rider", + ClientEntityTarget::TargetSelf => "self", + } + } +} + +fn preproccess_command( + session_state: &mut SessionState, + command: &ChatCommandKind, + args: &mut [String], +) -> CommandResult { + let mut cmd_args = match command { + ChatCommandKind::Client(cmd) => cmd.data().args, + ChatCommandKind::Server(cmd) => cmd.data().args, + }; + let client = &mut session_state.client.borrow_mut(); + let ecs = client.state().ecs(); + let player = ecs.read_resource::().0; + let mut command_start = 0; + for (i, arg) in args.iter_mut().enumerate() { + let mut could_be_entity_target = false; + if let Some(post_cmd_args) = cmd_args.get(i - command_start..) { + for (j, arg_spec) in post_cmd_args.iter().enumerate() { + match arg_spec { + ArgumentSpec::EntityTarget(_) => could_be_entity_target = true, + ArgumentSpec::SubCommand => { + if let Some(sub_command) = + ServerChatCommand::iter().find(|cmd| cmd.keyword() == arg) + { + cmd_args = sub_command.data().args; + command_start = i + j + 1; + break; + } + }, + _ => {}, + } + if matches!(arg_spec.requirement(), Requirement::Required) { + break; + } + } + } else if matches!(cmd_args.last(), Some(ArgumentSpec::SubCommand)) { + could_be_entity_target = true; + } + if could_be_entity_target && arg.starts_with(ClientEntityTarget::PREFIX) { + let target_str = arg.trim_start_matches(ClientEntityTarget::PREFIX); + let target = ClientEntityTarget::iter() + .find(|t| t.keyword() == target_str) + .ok_or_else(|| { + let help_string = ClientEntityTarget::iter() + .map(|t| t.keyword().to_string()) + .reduce(|a, b| format!("{a}/{b}")) + .unwrap_or_default(); + format!("Expected {help_string} after '@' found {target_str}") + })?; + let uid = match target { + ClientEntityTarget::Target => session_state + .target_entity + .and_then(|e| ecs.uid_from_entity(e)) + .ok_or("Not looking at a valid target".to_string())?, + ClientEntityTarget::Selected => session_state + .selected_entity + .and_then(|(e, _)| ecs.uid_from_entity(e)) + .ok_or("You don't have a valid target selected".to_string())?, + ClientEntityTarget::Viewpoint => session_state + .viewpoint_entity + .and_then(|e| ecs.uid_from_entity(e)) + .ok_or("Not viewing from a valid viewpoint entity".to_string())?, + ClientEntityTarget::Mount => { + if let Some(player) = player { + ecs.read_storage::>() + .get(player) + .map(|is_rider| is_rider.mount) + .or(ecs.read_storage::>().get(player).and_then( + |is_rider| match is_rider.pos.kind { + common::mounting::Volume::Terrain => None, + common::mounting::Volume::Entity(uid) => Some(uid), + }, + )) + .ok_or("Not riding a valid entity".to_string())? + } else { + return Err("No player entity".to_string()); + } + }, + ClientEntityTarget::Rider => { + if let Some(player) = player { + ecs.read_storage::>() + .get(player) + .map(|is_mount| is_mount.rider) + .ok_or("No valid rider".to_string())? + } else { + return Err("No player entity".to_string()); + } + }, + ClientEntityTarget::TargetSelf => player + .and_then(|e| ecs.uid_from_entity(e)) + .ok_or("No player entity")?, + }; + let uid = u64::from(uid); + *arg = format!("uid@{uid}"); + } + } + + Ok(None) +} + /// Runs a command by either sending it to the server or processing it /// locally. Returns a String to be output to the chat. // Note: it's not clear what data future commands will need access to, so the // signature of this function might change pub fn run_command( - client: &mut Client, + session_state: &mut SessionState, global_state: &mut GlobalState, cmd: &str, - args: Vec, + mut args: Vec, ) -> CommandResult { - let command = ChatCommandKind::from_str(cmd); + let command = ChatCommandKind::from_str(cmd) + .map_err(|_| invalid_command_message(&session_state.client.borrow(), cmd.to_string()))?; + + preproccess_command(session_state, &command, &mut args)?; + + let client = &mut session_state.client.borrow_mut(); match command { - Ok(ChatCommandKind::Server(cmd)) => { + ChatCommandKind::Server(cmd) => { client.send_command(cmd.keyword().into(), args); Ok(None) // The server will provide a response when the command is run }, - Ok(ChatCommandKind::Client(cmd)) => { + ChatCommandKind::Client(cmd) => { Ok(Some(run_client_command(client, global_state, cmd, args)?)) }, - Err(()) => Err(invalid_command_message(client, cmd.to_string())), } } @@ -383,6 +525,47 @@ impl TabComplete for ArgumentSpec { fn complete(&self, part: &str, client: &Client) -> Vec { match self { ArgumentSpec::PlayerName(_) => complete_player(part, client), + ArgumentSpec::EntityTarget(_) => { + if let Some((spec, end)) = part.split_once(ClientEntityTarget::PREFIX) { + match spec { + "" => ClientEntityTarget::iter() + .filter_map(|target| { + let ident = target.keyword(); + if ident.starts_with(end) { + Some(format!("@{ident}")) + } else { + None + } + }) + .collect(), + "uid" => { + if let Some(end) = + u64::from_str(end).ok().or(end.is_empty().then_some(0)) + { + client + .state() + .ecs() + .read_storage::() + .join() + .filter_map(|uid| { + let uid = u64::from(*uid); + if end < uid { + Some(format!("uid@{uid}")) + } else { + None + } + }) + .collect() + } else { + vec![] + } + }, + _ => vec![], + } + } else { + complete_player(part, client) + } + }, ArgumentSpec::SiteName(_) => complete_site(part, client), ArgumentSpec::Float(_, x, _) => { if part.is_empty() { diff --git a/voxygen/src/session/mod.rs b/voxygen/src/session/mod.rs index bc3a24d512..a8eee89fd5 100644 --- a/voxygen/src/session/mod.rs +++ b/voxygen/src/session/mod.rs @@ -90,7 +90,7 @@ enum TickAction { pub struct SessionState { scene: Scene, - client: Rc>, + pub(crate) client: Rc>, metadata: UpdateCharacterMetadata, hud: Hud, key_state: KeyState, @@ -104,9 +104,9 @@ pub struct SessionState { camera_clamp: bool, zoom_lock: bool, is_aiming: bool, - target_entity: Option, - selected_entity: Option<(specs::Entity, std::time::Instant)>, - viewpoint_entity: Option, + pub(crate) target_entity: Option, + pub(crate) selected_entity: Option<(specs::Entity, std::time::Instant)>, + pub(crate) viewpoint_entity: Option, interactable: Option, #[cfg(not(target_os = "macos"))] mumble_link: SharedLink, @@ -1530,8 +1530,7 @@ impl PlayState for SessionState { self.client.borrow_mut().send_chat(msg); }, HudEvent::SendCommand(name, args) => { - match run_command(&mut self.client.borrow_mut(), global_state, &name, args) - { + match run_command(self, global_state, &name, args) { Ok(Some(info)) => { // TODO: Localise self.hud