Skip to main content

veloren_voxygen/
cmd.rs

1//! This module handles client-side chat commands and command processing.
2//!
3//! It provides functionality for:
4//! - Defining client-side chat commands
5//! - Processing and executing commands (both client and server commands)
6//! - Command argument parsing and validation
7//! - Tab completion for command arguments
8//! - Entity targeting via special syntax (e.g., @target, @self)
9//!
10//! The command system allows players to interact with the game through text
11//! commands prefixed with a slash (e.g., /help, /wiki).
12
13use std::{num::NonZeroU64, str::FromStr};
14
15use crate::{
16    GlobalState,
17    render::ExperimentalShader,
18    session::{SessionState, settings_change::change_render_mode},
19};
20use client::Client;
21use common::{
22    cmd::*,
23    comp::Admin,
24    link::Is,
25    mounting::{Mount, Rider, VolumeRider},
26    parse_cmd_args,
27    resources::PlayerEntity,
28    uid::Uid,
29};
30use common_i18n::{Content, LocalizationArg};
31use common_net::sync::WorldSyncExt;
32use i18n::Localization;
33use itertools::Itertools;
34use levenshtein::levenshtein;
35use specs::{Join, WorldExt};
36use strum::{EnumIter, IntoEnumIterator};
37
38/// Represents all available client-side chat commands.
39///
40/// These commands are processed locally by the client without sending
41/// requests to the server. Each command provides specific client-side
42/// functionality like clearing the chat, accessing help, or managing
43/// user preferences.
44// Please keep this sorted alphabetically, same as with server commands :-)
45#[derive(Clone, Copy, strum::EnumIter)]
46pub enum ClientChatCommand {
47    /// Clears the chat window
48    Clear,
49    /// Toggles experimental shader features
50    ExperimentalShader,
51    /// Displays help information about commands
52    Help,
53    /// Mutes a player in the chat
54    Mute,
55    /// Toggles use of naga for shader processing (change not persisted).
56    Naga,
57    /// Resets the state of the tutorial
58    ResetTutorial,
59    /// Unmutes a previously muted player
60    Unmute,
61    /// Displays the name of the site or biome where the current waypoint is
62    /// located.
63    Waypoint,
64    /// Opens the Veloren wiki in a browser
65    Wiki,
66}
67
68impl ClientChatCommand {
69    /// Returns metadata about the command including its arguments and
70    /// description.
71    ///
72    /// This information is used for command processing, validation, and help
73    /// text generation.
74    pub fn data(&self) -> ChatCommandData {
75        use ArgumentSpec::*;
76        use Requirement::*;
77        let cmd = ChatCommandData::new;
78        match self {
79            ClientChatCommand::Clear => {
80                cmd(Vec::new(), Content::localized("command-clear-desc"), None)
81            },
82            ClientChatCommand::ExperimentalShader => cmd(
83                vec![Enum(
84                    "Shader",
85                    ExperimentalShader::iter()
86                        .map(|item| item.to_string())
87                        .collect(),
88                    Optional,
89                )],
90                Content::localized("command-experimental_shader-desc"),
91                None,
92            ),
93            ClientChatCommand::Help => cmd(
94                vec![Command(Optional)],
95                Content::localized("command-help-desc"),
96                None,
97            ),
98            ClientChatCommand::Naga => cmd(vec![], Content::localized("command-naga-desc"), None),
99            ClientChatCommand::Mute => cmd(
100                vec![PlayerName(Required)],
101                Content::localized("command-mute-desc"),
102                None,
103            ),
104            ClientChatCommand::Unmute => cmd(
105                vec![PlayerName(Required)],
106                Content::localized("command-unmute-desc"),
107                None,
108            ),
109            ClientChatCommand::Waypoint => {
110                cmd(vec![], Content::localized("command-waypoint-desc"), None)
111            },
112            ClientChatCommand::Wiki => cmd(
113                vec![Any("topic", Optional)],
114                Content::localized("command-wiki-desc"),
115                None,
116            ),
117            ClientChatCommand::ResetTutorial => cmd(
118                vec![],
119                Content::localized("command-reset_tutorial-desc"),
120                None,
121            ),
122        }
123    }
124
125    /// Returns the command's keyword (the text used to invoke the command).
126    ///
127    /// For example, the Help command is invoked with "/help".
128    pub fn keyword(&self) -> &'static str {
129        match self {
130            ClientChatCommand::Clear => "clear",
131            ClientChatCommand::ExperimentalShader => "experimental_shader",
132            ClientChatCommand::Help => "help",
133            ClientChatCommand::Naga => "naga",
134            ClientChatCommand::Mute => "mute",
135            ClientChatCommand::Unmute => "unmute",
136            ClientChatCommand::Waypoint => "waypoint",
137            ClientChatCommand::Wiki => "wiki",
138            ClientChatCommand::ResetTutorial => "reset_tutorial",
139        }
140    }
141
142    /// A message that explains what the command does
143    pub fn help_content(&self) -> Content {
144        let data = self.data();
145
146        let usage = std::iter::once(format!("/{}", self.keyword()))
147            .chain(data.args.iter().map(|arg| arg.usage_string()))
148            .collect::<Vec<_>>()
149            .join(" ");
150
151        Content::localized_with_args("command-help-template", [
152            ("usage", Content::Plain(usage)),
153            ("description", data.description),
154        ])
155    }
156
157    /// Produce an iterator over all the available commands
158    pub fn iter() -> impl Iterator<Item = Self> + Clone {
159        <Self as strum::IntoEnumIterator>::iter()
160    }
161
162    /// Produce an iterator that first goes over all the short keywords
163    /// and their associated commands and then iterates over all the normal
164    /// keywords with their associated commands
165    pub fn iter_with_keywords() -> impl Iterator<Item = (&'static str, Self)> {
166        Self::iter().map(|c| (c.keyword(), c))
167    }
168}
169
170impl FromStr for ClientChatCommand {
171    type Err = ();
172
173    fn from_str(keyword: &str) -> Result<ClientChatCommand, ()> {
174        Self::iter()
175            .map(|c| (c.keyword(), c))
176            .find_map(|(kwd, command)| (kwd == keyword).then_some(command))
177            .ok_or(())
178    }
179}
180
181/// Represents either a client-side or server-side command.
182///
183/// This enum is used to distinguish between commands that are processed
184/// locally by the client and those that need to be sent to the server
185/// for processing.
186#[derive(Clone, Copy)]
187pub enum ChatCommandKind {
188    Client(ClientChatCommand),
189    Server(ServerChatCommand),
190}
191
192impl FromStr for ChatCommandKind {
193    type Err = ();
194
195    fn from_str(s: &str) -> Result<Self, ()> {
196        if let Ok(cmd) = s.parse::<ClientChatCommand>() {
197            Ok(ChatCommandKind::Client(cmd))
198        } else if let Ok(cmd) = s.parse::<ServerChatCommand>() {
199            Ok(ChatCommandKind::Server(cmd))
200        } else {
201            Err(())
202        }
203    }
204}
205
206/// Represents the feedback shown to the user of a command, if any. Server
207/// commands give their feedback as an event, so in those cases this will always
208/// be Ok(None). An Err variant will be be displayed with the error icon and
209/// text color.
210///
211/// - Ok(Some(Content)) - Success with a message to display
212/// - Ok(None) - Success with no message (server commands typically use this)
213/// - Err(Content) - Error with a message to display
214type CommandResult = Result<Option<Content>, Content>;
215
216/// Special entity targets that can be referenced in commands using @ syntax.
217///
218/// This allows players to reference entities in commands without knowing
219/// their specific UIDs, using contextual references like @target or @self.
220#[derive(EnumIter)]
221enum ClientEntityTarget {
222    /// The entity the player is currently looking at/targeting
223    Target,
224    /// The entity the player has explicitly selected
225    Selected,
226    /// The entity from whose perspective the player is viewing the world
227    Viewpoint,
228    /// The entity the player is mounted on (if any)
229    Mount,
230    /// The entity that is riding the player (if any)
231    Rider,
232    /// The player's own entity
233    TargetSelf,
234}
235
236impl ClientEntityTarget {
237    const PREFIX: char = '@';
238
239    fn keyword(&self) -> &'static str {
240        match self {
241            ClientEntityTarget::Target => "target",
242            ClientEntityTarget::Selected => "selected",
243            ClientEntityTarget::Viewpoint => "viewpoint",
244            ClientEntityTarget::Mount => "mount",
245            ClientEntityTarget::Rider => "rider",
246            ClientEntityTarget::TargetSelf => "self",
247        }
248    }
249}
250
251/// Preprocesses command arguments before execution.
252///
253/// This function handles special syntax like entity targeting (e.g., @target,
254/// @self) and resolves them to actual entity UIDs. It also handles subcommands
255/// and asset path prefixing.
256fn preproccess_command(
257    session_state: &mut SessionState,
258    command: &ChatCommandKind,
259    args: &mut [String],
260) -> CommandResult {
261    // Get the argument specifications for the command
262    let mut cmd_args = match command {
263        ChatCommandKind::Client(cmd) => cmd.data().args,
264        ChatCommandKind::Server(cmd) => cmd.data().args,
265    };
266    let client = &mut session_state.client.borrow_mut();
267    let ecs = client.state().ecs();
268    let player = ecs.read_resource::<PlayerEntity>().0;
269
270    let mut command_start = 0;
271
272    for (i, arg) in args.iter_mut().enumerate() {
273        let mut could_be_entity_target = false;
274
275        if let Some(post_cmd_args) = cmd_args.get(i - command_start..) {
276            for (j, arg_spec) in post_cmd_args.iter().enumerate() {
277                match arg_spec {
278                    ArgumentSpec::EntityTarget(_) => could_be_entity_target = true,
279
280                    ArgumentSpec::SubCommand => {
281                        if let Some(sub_command) =
282                            ServerChatCommand::iter().find(|cmd| cmd.keyword() == arg)
283                        {
284                            cmd_args = sub_command.data().args;
285                            command_start = i + j + 1;
286                            break;
287                        }
288                    },
289
290                    ArgumentSpec::AssetPath(_, prefix, _, _) => {
291                        *arg = prefix.to_string() + arg;
292                    },
293                    _ => {},
294                }
295
296                if matches!(arg_spec.requirement(), Requirement::Required) {
297                    break;
298                }
299            }
300        } else if matches!(cmd_args.last(), Some(ArgumentSpec::SubCommand)) {
301            // If we're past the defined args but the last arg was a subcommand,
302            // we could still have entity targets in subcommand args
303            could_be_entity_target = true;
304        }
305        // Process entity targeting syntax (e.g., @target, @self)
306        if could_be_entity_target && arg.starts_with(ClientEntityTarget::PREFIX) {
307            // Extract the target keyword (e.g., "target" from "@target")
308            let target_str = arg.trim_start_matches(ClientEntityTarget::PREFIX);
309
310            // Find the matching target type
311            let target = ClientEntityTarget::iter()
312                .find(|t| t.keyword() == target_str)
313                .ok_or_else(|| {
314                    // Generate error with list of valid targets if not found
315                    let expected_list = ClientEntityTarget::iter()
316                        .map(|t| t.keyword().to_string())
317                        .collect::<Vec<String>>()
318                        .join("/");
319                    Content::localized_with_args("command-preprocess-target-error", [
320                        ("expected_list", LocalizationArg::from(expected_list)),
321                        ("target", LocalizationArg::from(target_str)),
322                    ])
323                })?;
324            let uid = match target {
325                ClientEntityTarget::Target => session_state
326                    .target_entity
327                    .and_then(|e| ecs.uid_from_entity(e))
328                    .ok_or(Content::localized(
329                        "command-preprocess-not-looking-at-valid-target",
330                    ))?,
331                ClientEntityTarget::Selected => session_state
332                    .selected_entity
333                    .and_then(|(e, _)| ecs.uid_from_entity(e))
334                    .ok_or(Content::localized(
335                        "command-preprocess-not-selected-valid-target",
336                    ))?,
337                ClientEntityTarget::Viewpoint => session_state
338                    .viewpoint_entity
339                    .and_then(|e| ecs.uid_from_entity(e))
340                    .ok_or(Content::localized(
341                        "command-preprocess-not-valid-viewpoint-entity",
342                    ))?,
343                ClientEntityTarget::Mount => {
344                    if let Some(player) = player {
345                        ecs.read_storage::<Is<Rider>>()
346                            .get(player)
347                            .map(|is_rider| is_rider.mount)
348                            .or(ecs.read_storage::<Is<VolumeRider>>().get(player).and_then(
349                                |is_rider| match is_rider.pos.kind {
350                                    common::mounting::Volume::Terrain => None,
351                                    common::mounting::Volume::Entity(uid) => Some(uid),
352                                },
353                            ))
354                            .ok_or(Content::localized(
355                                "command-preprocess-not-riding-valid-entity",
356                            ))?
357                    } else {
358                        return Err(Content::localized("command-preprocess-no-player-entity"));
359                    }
360                },
361                ClientEntityTarget::Rider => {
362                    if let Some(player) = player {
363                        ecs.read_storage::<Is<Mount>>()
364                            .get(player)
365                            .map(|is_mount| is_mount.rider)
366                            .ok_or(Content::localized("command-preprocess-not-valid-rider"))?
367                    } else {
368                        return Err(Content::localized("command-preprocess-no-player-entity"));
369                    }
370                },
371                ClientEntityTarget::TargetSelf => player
372                    .and_then(|e| ecs.uid_from_entity(e))
373                    .ok_or(Content::localized("command-preprocess-no-player-entity"))?,
374            };
375
376            // Convert the target to a UID string format
377            let uid = NonZeroU64::from(uid);
378            *arg = format!("uid@{uid}");
379        }
380    }
381
382    Ok(None)
383}
384
385/// Runs a command by either sending it to the server or processing it locally.
386///
387/// This is the main entry point for executing chat commands. It parses the
388/// command, preprocesses its arguments, and then either:
389/// - Sends server commands to the server for processing
390/// - Processes client commands locally
391pub fn run_command(
392    session_state: &mut SessionState,
393    global_state: &mut GlobalState,
394    cmd: &str,
395    mut args: Vec<String>,
396) -> CommandResult {
397    let command = ChatCommandKind::from_str(cmd)
398        .map_err(|_| invalid_command_message(&session_state.client.borrow(), cmd.to_string()))?;
399
400    preproccess_command(session_state, &command, &mut args)?;
401
402    match command {
403        ChatCommandKind::Server(cmd) => {
404            session_state
405                .client
406                .borrow_mut()
407                .send_command(cmd.keyword().into(), args);
408            Ok(None) // The server will provide a response when the command is
409            // run
410        },
411        ChatCommandKind::Client(cmd) => run_client_command(session_state, global_state, cmd, args),
412    }
413}
414
415/// Generates a helpful error message when an invalid command is entered.
416fn invalid_command_message(client: &Client, user_entered_invalid_command: String) -> Content {
417    let entity_role = client
418        .state()
419        .read_storage::<Admin>()
420        .get(client.entity())
421        .map(|admin| admin.0);
422
423    let usable_commands = ServerChatCommand::iter()
424        .filter(|cmd| cmd.needs_role() <= entity_role)
425        .map(|cmd| cmd.keyword())
426        .chain(ClientChatCommand::iter().map(|cmd| cmd.keyword()));
427
428    let most_similar_cmd = usable_commands
429        .clone()
430        .min_by_key(|cmd| levenshtein(&user_entered_invalid_command, cmd))
431        .expect("At least one command exists.");
432
433    let commands_with_same_prefix = usable_commands
434        .filter(|cmd| cmd.starts_with(&user_entered_invalid_command) && cmd != &most_similar_cmd);
435
436    Content::localized_with_args("command-invalid-command-message", [
437        (
438            "invalid-command",
439            LocalizationArg::from(user_entered_invalid_command.clone()),
440        ),
441        (
442            "most-similar-command",
443            LocalizationArg::from(String::from("/") + most_similar_cmd),
444        ),
445        (
446            "commands-with-same-prefix",
447            LocalizationArg::from(
448                commands_with_same_prefix
449                    .map(|cmd| format!("/{cmd}"))
450                    .collect::<String>(),
451            ),
452        ),
453    ])
454}
455
456/// Executes a client-side command.
457///
458/// This function dispatches to the appropriate handler function based on the
459/// command.
460fn run_client_command(
461    session_state: &mut SessionState,
462    global_state: &mut GlobalState,
463    command: ClientChatCommand,
464    args: Vec<String>,
465) -> CommandResult {
466    let command = match command {
467        ClientChatCommand::Clear => handle_clear,
468        ClientChatCommand::ExperimentalShader => handle_experimental_shader,
469        ClientChatCommand::Help => handle_help,
470        ClientChatCommand::Naga => handle_naga,
471        ClientChatCommand::Mute => handle_mute,
472        ClientChatCommand::Unmute => handle_unmute,
473        ClientChatCommand::Waypoint => handle_waypoint,
474        ClientChatCommand::Wiki => handle_wiki,
475        ClientChatCommand::ResetTutorial => handle_reset_tutorial,
476    };
477
478    command(session_state, global_state, args)
479}
480
481/// Handles [`ClientChatCommand::Clear`]
482fn handle_clear(
483    session_state: &mut SessionState,
484    _global_state: &mut GlobalState,
485    _args: Vec<String>,
486) -> CommandResult {
487    session_state.hud.clear_chat();
488    Ok(None)
489}
490
491/// Handles [`ClientChatCommand::ExperimentalShader`]
492fn handle_experimental_shader(
493    _session_state: &mut SessionState,
494    global_state: &mut GlobalState,
495    args: Vec<String>,
496) -> CommandResult {
497    if args.is_empty() {
498        Ok(Some(Content::localized_with_args(
499            "command-experimental-shaders-list",
500            [(
501                "shader-list",
502                LocalizationArg::from(
503                    ExperimentalShader::iter()
504                        .map(|s| {
505                            let is_active = global_state
506                                .settings
507                                .graphics
508                                .render_mode
509                                .experimental_shaders
510                                .contains(&s);
511                            format!("[{}] {}", if is_active { "x" } else { "  " }, s)
512                        })
513                        .collect::<Vec<String>>()
514                        .join("/"),
515                ),
516            )],
517        )))
518    } else if let Some(item) = parse_cmd_args!(args, String) {
519        if let Ok(shader) = ExperimentalShader::from_str(&item) {
520            let mut new_render_mode = global_state.settings.graphics.render_mode.clone();
521            let res = if new_render_mode.experimental_shaders.remove(&shader) {
522                Ok(Some(Content::localized_with_args(
523                    "command-experimental-shaders-disabled",
524                    [("shader", LocalizationArg::from(item))],
525                )))
526            } else if !shader.is_supported() {
527                Err(Content::localized_with_args(
528                    "command-experimental-shaders-not-supported",
529                    [("shader", LocalizationArg::from(item))],
530                ))
531            } else {
532                new_render_mode.experimental_shaders.insert(shader);
533                Ok(Some(Content::localized_with_args(
534                    "command-experimental-shaders-enabled",
535                    [("shader", LocalizationArg::from(item))],
536                )))
537            };
538
539            change_render_mode(
540                new_render_mode,
541                &mut global_state.window,
542                &mut global_state.settings,
543            );
544
545            res
546        } else {
547            Err(Content::localized_with_args(
548                "command-experimental-shaders-not-a-shader",
549                [("shader", LocalizationArg::from(item))],
550            ))
551        }
552    } else {
553        Err(Content::localized("command-experimental-shaders-not-valid"))
554    }
555}
556
557/// Handles [`ClientChatCommand::Help`]
558///
559/// If a command name is provided as an argument, displays help for that
560/// specific command. Otherwise, displays a list of all available commands the
561/// player can use, filtered by their administrative role.
562fn handle_help(
563    session_state: &mut SessionState,
564    global_state: &mut GlobalState,
565    args: Vec<String>,
566) -> CommandResult {
567    let i18n = global_state.i18n.read();
568
569    if let Some(cmd) = parse_cmd_args!(&args, ServerChatCommand) {
570        Ok(Some(cmd.help_content()))
571    } else if let Some(cmd) = parse_cmd_args!(&args, ClientChatCommand) {
572        Ok(Some(cmd.help_content()))
573    } else {
574        let client = &mut session_state.client.borrow_mut();
575
576        let entity_role = client
577            .state()
578            .read_storage::<Admin>()
579            .get(client.entity())
580            .map(|admin| admin.0);
581
582        let client_commands = ClientChatCommand::iter()
583            .map(|cmd| i18n.get_content(&cmd.help_content()))
584            .join("\n");
585
586        // Iterate through all ServerChatCommands you have permission to use.
587        let server_commands = ServerChatCommand::iter()
588            .filter(|cmd| cmd.needs_role() <= entity_role)
589            .map(|cmd| i18n.get_content(&cmd.help_content()))
590            .join("\n");
591
592        let additional_shortcuts = ServerChatCommand::iter()
593            .filter(|cmd| cmd.needs_role() <= entity_role)
594            .filter_map(|cmd| cmd.short_keyword().map(|k| (k, cmd)))
595            .map(|(k, cmd)| format!("/{} => /{}", k, cmd.keyword()))
596            .join("\n");
597
598        Ok(Some(Content::localized_with_args("command-help-list", [
599            ("client-commands", LocalizationArg::from(client_commands)),
600            ("server-commands", LocalizationArg::from(server_commands)),
601            (
602                "additional-shortcuts",
603                LocalizationArg::from(additional_shortcuts),
604            ),
605        ])))
606    }
607}
608
609/// Handles [`ClientChatCommand::Naga`]
610///
611///Toggles use of naga in initial shader processing.
612fn handle_naga(
613    _session_state: &mut SessionState,
614    global_state: &mut GlobalState,
615    _args: Vec<String>,
616) -> CommandResult {
617    let mut new_render_mode = global_state.settings.graphics.render_mode.clone();
618    new_render_mode.enable_naga ^= true;
619    let naga_enabled = new_render_mode.enable_naga;
620    change_render_mode(
621        new_render_mode,
622        &mut global_state.window,
623        &mut global_state.settings,
624    );
625
626    Ok(Some(Content::localized_with_args(
627        "command-shader-backend",
628        [(
629            "shader-backend",
630            if naga_enabled {
631                LocalizationArg::from("naga")
632            } else {
633                LocalizationArg::from("shaderc")
634            },
635        )],
636    )))
637}
638
639/// Handles [`ClientChatCommand::Mute`]
640fn handle_mute(
641    session_state: &mut SessionState,
642    global_state: &mut GlobalState,
643    args: Vec<String>,
644) -> CommandResult {
645    if let Some(alias) = parse_cmd_args!(args, String) {
646        let client = &mut session_state.client.borrow_mut();
647
648        let target = client
649            .player_list()
650            .values()
651            .find(|p| p.player_alias == alias)
652            .ok_or_else(|| {
653                Content::localized_with_args("command-mute-no-player-found", [(
654                    "player",
655                    LocalizationArg::from(alias.clone()),
656                )])
657            })?;
658
659        if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid))
660            && target.uuid == me.uuid
661        {
662            return Err(Content::localized("command-mute-cannot-mute-self"));
663        }
664
665        if global_state
666            .profile
667            .mutelist
668            .insert(target.uuid, alias.clone())
669            .is_none()
670        {
671            Ok(Some(Content::localized_with_args(
672                "command-mute-success",
673                [("player", LocalizationArg::from(alias))],
674            )))
675        } else {
676            Err(Content::localized_with_args(
677                "command-mute-already-muted",
678                [("player", LocalizationArg::from(alias))],
679            ))
680        }
681    } else {
682        Err(Content::localized("command-mute-no-player-specified"))
683    }
684}
685
686/// Handles [`ClientChatCommand::Unmute`]
687fn handle_unmute(
688    session_state: &mut SessionState,
689    global_state: &mut GlobalState,
690    args: Vec<String>,
691) -> CommandResult {
692    // Note that we don't care if this is a real player currently online,
693    // so that it's possible to unmute someone when they're offline.
694    if let Some(alias) = parse_cmd_args!(args, String) {
695        if let Some(uuid) = global_state
696            .profile
697            .mutelist
698            .iter()
699            .find(|(_, v)| **v == alias)
700            .map(|(k, _)| *k)
701        {
702            let client = &mut session_state.client.borrow_mut();
703
704            if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid))
705                && uuid == me.uuid
706            {
707                return Err(Content::localized("command-unmute-cannot-unmute-self"));
708            }
709
710            global_state.profile.mutelist.remove(&uuid);
711
712            Ok(Some(Content::localized_with_args(
713                "command-unmute-success",
714                [("player", LocalizationArg::from(alias))],
715            )))
716        } else {
717            Err(Content::localized_with_args(
718                "command-unmute-no-muted-player-found",
719                [("player", LocalizationArg::from(alias))],
720            ))
721        }
722    } else {
723        Err(Content::localized("command-unmute-no-player-specified"))
724    }
725}
726
727/// Handles [`ClientChatCommand::Waypoint`]
728fn handle_waypoint(
729    session_state: &mut SessionState,
730    _global_state: &mut GlobalState,
731    _args: Vec<String>,
732) -> CommandResult {
733    let client = &mut session_state.client.borrow();
734
735    if let Some(waypoint) = client.waypoint() {
736        Ok(Some(Content::localized_with_args(
737            "command-waypoint-result",
738            [("waypoint", LocalizationArg::from(waypoint.clone()))],
739        )))
740    } else {
741        Err(Content::localized("command-waypoint-error"))
742    }
743}
744
745/// Handles [`ClientChatCommand::Wiki`]
746///
747/// With no arguments, opens the wiki homepage.
748/// With arguments, performs a search on the wiki for the specified terms.
749/// Returns an error if the browser fails to open.
750fn handle_wiki(
751    _session_state: &mut SessionState,
752    _global_state: &mut GlobalState,
753    args: Vec<String>,
754) -> CommandResult {
755    let url = if args.is_empty() {
756        "https://wiki.veloren.net/".to_string()
757    } else {
758        let query_string = args.join("+");
759
760        format!("https://wiki.veloren.net/w/index.php?search={query_string}")
761    };
762
763    open::that_detached(url)
764        .map(|_| Some(Content::localized("command-wiki-success")))
765        .map_err(|e| {
766            Content::localized_with_args("command-wiki-fail", [(
767                "error",
768                LocalizationArg::from(e.to_string()),
769            )])
770        })
771}
772
773/// Handles [`ClientChatCommand::ResetTutorial`]
774///
775/// Useful for debugging the tutorial or showing the tutorial again.
776fn handle_reset_tutorial(
777    _session_state: &mut SessionState,
778    global_state: &mut GlobalState,
779    _args: Vec<String>,
780) -> CommandResult {
781    global_state.profile.tutorial = Default::default();
782    Ok(Some(Content::localized("command-reset_tutorial-success")))
783}
784
785/// Trait for types that can provide tab completion suggestions.
786///
787/// This trait is implemented by types that can generate a list of possible
788/// completions for a partial input string.
789trait TabComplete {
790    fn complete(&self, part: &str, client: &Client, i18n: &Localization) -> Vec<String>;
791}
792
793impl TabComplete for ArgumentSpec {
794    fn complete(&self, part: &str, client: &Client, i18n: &Localization) -> Vec<String> {
795        match self {
796            ArgumentSpec::PlayerName(_) => complete_player(part, client),
797            ArgumentSpec::EntityTarget(_) => {
798                // Check if the input starts with the entity target prefix '@'
799                if let Some((spec, end)) = part.split_once(ClientEntityTarget::PREFIX) {
800                    match spec {
801                        // If it's just "@", complete with all possible target keywords
802                        "" => ClientEntityTarget::iter()
803                            .filter_map(|target| {
804                                let ident = target.keyword();
805                                if ident.starts_with(end) {
806                                    Some(format!("@{ident}"))
807                                } else {
808                                    None
809                                }
810                            })
811                            .collect(),
812                        // If it's "@uid", complete with actual UIDs from the ECS
813                        "uid" => {
814                            let end_res = end.trim().parse().map_err(|_| Vec::<String>::new());
815                            client
816                                .state()
817                                .ecs()
818                                .read_storage::<Uid>()
819                                .join()
820                                .filter_map(|uid: &Uid| {
821                                    let u = uid.0;
822                                    match end_res {
823                                        Ok(e) if u > e => Some(format!("uid@{}", u.get())),
824                                        Ok(_) => None,
825                                        Err(_) => None,
826                                    }
827                                })
828                                .collect()
829                        },
830                        _ => vec![],
831                    }
832                } else {
833                    complete_player(part, client)
834                }
835            },
836            ArgumentSpec::SiteName(_) => complete_site(part, client, i18n),
837            ArgumentSpec::Float(_, x, _) => {
838                if part.is_empty() {
839                    vec![format!("{:.1}", x)] // Suggest default with one decimal place
840                } else {
841                    vec![] // No suggestions if already typing
842                }
843            },
844            ArgumentSpec::Integer(_, x, _) => {
845                if part.is_empty() {
846                    vec![format!("{}", x)]
847                } else {
848                    vec![]
849                }
850            },
851            // No specific completion for arbitrary 'Any' arguments
852            ArgumentSpec::Any(_, _) => vec![],
853            ArgumentSpec::Command(_) => complete_command(part, ""),
854            ArgumentSpec::Message(_) => complete_player(part, client),
855            ArgumentSpec::SubCommand => complete_command(part, ""),
856            ArgumentSpec::Enum(_, strings, _) => strings
857                .iter()
858                .filter(|string| string.starts_with(part)) // Filter by partial input
859                .map(|c| c.to_string())
860                .collect(),
861            // Complete with asset paths
862            ArgumentSpec::AssetPath(_, prefix, paths, _) => {
863                // If input starts with '#', search within paths
864                if let Some(part_stripped) = part.strip_prefix('#') {
865                    paths
866                        .iter()
867                        .filter(|string| string.contains(part_stripped))
868                        .filter_map(|c| Some(c.strip_prefix(prefix)?.to_string()))
869                        .collect()
870                } else {
871                    // Otherwise, complete based on path hierarchy
872                    let part_with_prefix = prefix.to_string() + part;
873                    let depth = part_with_prefix.split('.').count();
874                    paths
875                        .iter()
876                        .map(|path| path.as_str().split('.').take(depth).join("."))
877                        .dedup()
878                        .filter(|string| string.starts_with(&part_with_prefix))
879                        .filter_map(|c| Some(c.strip_prefix(prefix)?.to_string()))
880                        .collect()
881                }
882            },
883            ArgumentSpec::Boolean(_, part, _) => ["true", "false"]
884                .iter()
885                .filter(|string| string.starts_with(part))
886                .map(|c| c.to_string())
887                .collect(),
888            ArgumentSpec::Flag(part) => vec![part.to_string()],
889        }
890    }
891}
892
893/// Returns a list of player names that start with the given partial input.
894fn complete_player(part: &str, client: &Client) -> Vec<String> {
895    client
896        .player_list()
897        .values()
898        .map(|player_info| &player_info.player_alias)
899        .filter(|alias| alias.starts_with(part))
900        .cloned()
901        .collect()
902}
903
904/// Returns a list of site names that start with the given partial input.
905fn complete_site(mut part: &str, client: &Client, i18n: &Localization) -> Vec<String> {
906    if let Some(p) = part.strip_prefix('"') {
907        part = p;
908    }
909    client
910        .sites()
911        .values()
912        .filter_map(|site| match site.marker.kind {
913            common::map::MarkerKind::Cave => None,
914            // TODO: A bit of a hack: no guarantee that label will be the site name!
915            _ => Some(i18n.get_content(site.marker.label.as_ref()?)),
916        })
917        .filter(|name| name.starts_with(part))
918        .map(|name| {
919            if name.contains(' ') {
920                format!("\"{}\"", name)
921            } else {
922                name.clone()
923            }
924        })
925        .collect()
926}
927
928/// Gets the byte index of the nth word in a string.
929fn nth_word(line: &str, n: usize) -> Option<usize> {
930    let mut is_space = false;
931    let mut word_counter = 0;
932
933    for (i, c) in line.char_indices() {
934        match (is_space, c.is_whitespace()) {
935            (true, true) => {},
936            // start of a new word
937            (true, false) => {
938                is_space = false;
939                word_counter += 1;
940            },
941            // end of the current word
942            (false, true) => {
943                is_space = true;
944            },
945            (false, false) => {},
946        }
947
948        if word_counter == n {
949            return Some(i);
950        }
951    }
952
953    None
954}
955
956/// Returns a list of [`ClientChatCommand`] and [`ServerChatCommand`] names that
957/// start with the given partial input.
958fn complete_command(part: &str, prefix: &str) -> Vec<String> {
959    ServerChatCommand::iter_with_keywords()
960        .map(|(kwd, _)| kwd)
961        .chain(ClientChatCommand::iter_with_keywords().map(|(kwd, _)| kwd))
962        .filter(|kwd| kwd.starts_with(part))
963        .map(|kwd| format!("{}{}", prefix, kwd))
964        .collect()
965}
966
967/// Main tab completion function for chat input.
968///
969/// This function handles tab completion for both commands and regular chat.
970/// It determines what kind of completion is needed based on the input and
971/// delegates to the appropriate completion function.
972pub fn complete(line: &str, client: &Client, i18n: &Localization, cmd_prefix: &str) -> Vec<String> {
973    // Get the last word in the input line, which is what we're trying to complete
974    // If the line ends with whitespace, we're starting a new word
975    let word = if line.chars().last().is_none_or(char::is_whitespace) {
976        ""
977    } else {
978        line.split_whitespace().last().unwrap_or("")
979    };
980
981    // Check if we're completing a command (starts with the command prefix)
982    if line.starts_with(cmd_prefix) {
983        // Strip the command prefix for easier processing
984        let line = line.strip_prefix(cmd_prefix).unwrap_or(line);
985        let mut iter = line.split_whitespace();
986
987        // Get the command name (first word)
988        let cmd = iter.next().unwrap_or("");
989
990        // If the line ends with whitespace, we're starting a new argument
991        let argument_position = iter.count() + usize::from(word.is_empty());
992
993        // If we're at position 0, we're completing the command name itself
994        if argument_position == 0 {
995            // Completing chat command name. This is the start of the line so the prefix
996            // will be part of it
997            let word = word.strip_prefix(cmd_prefix).unwrap_or(word);
998            return complete_command(word, cmd_prefix);
999        }
1000
1001        // Try to parse the command to get its argument specifications
1002        let args = {
1003            if let Ok(cmd) = cmd.parse::<ServerChatCommand>() {
1004                Some(cmd.data().args)
1005            } else if let Ok(cmd) = cmd.parse::<ClientChatCommand>() {
1006                Some(cmd.data().args)
1007            } else {
1008                None
1009            }
1010        };
1011
1012        if let Some(args) = args {
1013            // If we're completing an argument that's defined in the command's spec
1014            if let Some(arg) = args.get(argument_position - 1) {
1015                // Complete the current argument using its type-specific completion
1016                arg.complete(word, client, i18n)
1017            } else {
1018                // We're past the defined arguments, handle special cases
1019                match args.last() {
1020                    // For subcommands (like in "/sudo player kill"), recursively complete
1021                    Some(ArgumentSpec::SubCommand) => {
1022                        // Find where the subcommand starts in the input
1023                        if let Some(index) = nth_word(line, args.len()) {
1024                            // Recursively complete the subcommand part
1025                            complete(&line[index..], client, i18n, "")
1026                        } else {
1027                            vec![]
1028                        }
1029                    },
1030                    // For message arguments, complete with player names
1031                    Some(ArgumentSpec::Message(_)) => complete_player(word, client),
1032                    _ => vec![],
1033                }
1034            }
1035        } else {
1036            complete_player(word, client)
1037        }
1038    } else {
1039        complete_player(word, client)
1040    }
1041}
1042
1043#[test]
1044fn verify_cmd_list_sorted() {
1045    let mut list = ClientChatCommand::iter()
1046        .map(|c| c.keyword())
1047        .collect::<Vec<_>>();
1048
1049    // Vec::is_sorted is unstable, so we do it the hard way
1050    let list2 = list.clone();
1051    list.sort_unstable();
1052    assert_eq!(list, list2);
1053}
1054
1055#[test]
1056fn test_complete_command() {
1057    assert_eq!(complete_command("mu", "/"), vec!["/mute".to_string()]);
1058    assert_eq!(complete_command("unba", "/"), vec![
1059        "/unban".to_string(),
1060        "/unban_ip".to_string()
1061    ]);
1062    assert_eq!(complete_command("make_", "/"), vec![
1063        "/make_block".to_string(),
1064        "/make_npc".to_string(),
1065        "/make_sprite".to_string(),
1066        "/make_volume".to_string()
1067    ]);
1068}