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 {
527                new_render_mode.experimental_shaders.insert(shader);
528                Ok(Some(Content::localized_with_args(
529                    "command-experimental-shaders-enabled",
530                    [("shader", LocalizationArg::from(item))],
531                )))
532            };
533
534            change_render_mode(
535                new_render_mode,
536                &mut global_state.window,
537                &mut global_state.settings,
538            );
539
540            res
541        } else {
542            Err(Content::localized_with_args(
543                "command-experimental-shaders-not-a-shader",
544                [("shader", LocalizationArg::from(item))],
545            ))
546        }
547    } else {
548        Err(Content::localized("command-experimental-shaders-not-valid"))
549    }
550}
551
552/// Handles [`ClientChatCommand::Help`]
553///
554/// If a command name is provided as an argument, displays help for that
555/// specific command. Otherwise, displays a list of all available commands the
556/// player can use, filtered by their administrative role.
557fn handle_help(
558    session_state: &mut SessionState,
559    global_state: &mut GlobalState,
560    args: Vec<String>,
561) -> CommandResult {
562    let i18n = global_state.i18n.read();
563
564    if let Some(cmd) = parse_cmd_args!(&args, ServerChatCommand) {
565        Ok(Some(cmd.help_content()))
566    } else if let Some(cmd) = parse_cmd_args!(&args, ClientChatCommand) {
567        Ok(Some(cmd.help_content()))
568    } else {
569        let client = &mut session_state.client.borrow_mut();
570
571        let entity_role = client
572            .state()
573            .read_storage::<Admin>()
574            .get(client.entity())
575            .map(|admin| admin.0);
576
577        let client_commands = ClientChatCommand::iter()
578            .map(|cmd| i18n.get_content(&cmd.help_content()))
579            .join("\n");
580
581        // Iterate through all ServerChatCommands you have permission to use.
582        let server_commands = ServerChatCommand::iter()
583            .filter(|cmd| cmd.needs_role() <= entity_role)
584            .map(|cmd| i18n.get_content(&cmd.help_content()))
585            .join("\n");
586
587        let additional_shortcuts = ServerChatCommand::iter()
588            .filter(|cmd| cmd.needs_role() <= entity_role)
589            .filter_map(|cmd| cmd.short_keyword().map(|k| (k, cmd)))
590            .map(|(k, cmd)| format!("/{} => /{}", k, cmd.keyword()))
591            .join("\n");
592
593        Ok(Some(Content::localized_with_args("command-help-list", [
594            ("client-commands", LocalizationArg::from(client_commands)),
595            ("server-commands", LocalizationArg::from(server_commands)),
596            (
597                "additional-shortcuts",
598                LocalizationArg::from(additional_shortcuts),
599            ),
600        ])))
601    }
602}
603
604/// Handles [`ClientChatCommand::Naga`]
605///
606///Toggles use of naga in initial shader processing.
607fn handle_naga(
608    _session_state: &mut SessionState,
609    global_state: &mut GlobalState,
610    _args: Vec<String>,
611) -> CommandResult {
612    let mut new_render_mode = global_state.settings.graphics.render_mode.clone();
613    new_render_mode.enable_naga ^= true;
614    let naga_enabled = new_render_mode.enable_naga;
615    change_render_mode(
616        new_render_mode,
617        &mut global_state.window,
618        &mut global_state.settings,
619    );
620
621    Ok(Some(Content::localized_with_args(
622        "command-shader-backend",
623        [(
624            "shader-backend",
625            if naga_enabled {
626                LocalizationArg::from("naga")
627            } else {
628                LocalizationArg::from("shaderc")
629            },
630        )],
631    )))
632}
633
634/// Handles [`ClientChatCommand::Mute`]
635fn handle_mute(
636    session_state: &mut SessionState,
637    global_state: &mut GlobalState,
638    args: Vec<String>,
639) -> CommandResult {
640    if let Some(alias) = parse_cmd_args!(args, String) {
641        let client = &mut session_state.client.borrow_mut();
642
643        let target = client
644            .player_list()
645            .values()
646            .find(|p| p.player_alias == alias)
647            .ok_or_else(|| {
648                Content::localized_with_args("command-mute-no-player-found", [(
649                    "player",
650                    LocalizationArg::from(alias.clone()),
651                )])
652            })?;
653
654        if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid))
655            && target.uuid == me.uuid
656        {
657            return Err(Content::localized("command-mute-cannot-mute-self"));
658        }
659
660        if global_state
661            .profile
662            .mutelist
663            .insert(target.uuid, alias.clone())
664            .is_none()
665        {
666            Ok(Some(Content::localized_with_args(
667                "command-mute-success",
668                [("player", LocalizationArg::from(alias))],
669            )))
670        } else {
671            Err(Content::localized_with_args(
672                "command-mute-already-muted",
673                [("player", LocalizationArg::from(alias))],
674            ))
675        }
676    } else {
677        Err(Content::localized("command-mute-no-player-specified"))
678    }
679}
680
681/// Handles [`ClientChatCommand::Unmute`]
682fn handle_unmute(
683    session_state: &mut SessionState,
684    global_state: &mut GlobalState,
685    args: Vec<String>,
686) -> CommandResult {
687    // Note that we don't care if this is a real player currently online,
688    // so that it's possible to unmute someone when they're offline.
689    if let Some(alias) = parse_cmd_args!(args, String) {
690        if let Some(uuid) = global_state
691            .profile
692            .mutelist
693            .iter()
694            .find(|(_, v)| **v == alias)
695            .map(|(k, _)| *k)
696        {
697            let client = &mut session_state.client.borrow_mut();
698
699            if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid))
700                && uuid == me.uuid
701            {
702                return Err(Content::localized("command-unmute-cannot-unmute-self"));
703            }
704
705            global_state.profile.mutelist.remove(&uuid);
706
707            Ok(Some(Content::localized_with_args(
708                "command-unmute-success",
709                [("player", LocalizationArg::from(alias))],
710            )))
711        } else {
712            Err(Content::localized_with_args(
713                "command-unmute-no-muted-player-found",
714                [("player", LocalizationArg::from(alias))],
715            ))
716        }
717    } else {
718        Err(Content::localized("command-unmute-no-player-specified"))
719    }
720}
721
722/// Handles [`ClientChatCommand::Waypoint`]
723fn handle_waypoint(
724    session_state: &mut SessionState,
725    _global_state: &mut GlobalState,
726    _args: Vec<String>,
727) -> CommandResult {
728    let client = &mut session_state.client.borrow();
729
730    if let Some(waypoint) = client.waypoint() {
731        Ok(Some(Content::localized_with_args(
732            "command-waypoint-result",
733            [("waypoint", LocalizationArg::from(waypoint.clone()))],
734        )))
735    } else {
736        Err(Content::localized("command-waypoint-error"))
737    }
738}
739
740/// Handles [`ClientChatCommand::Wiki`]
741///
742/// With no arguments, opens the wiki homepage.
743/// With arguments, performs a search on the wiki for the specified terms.
744/// Returns an error if the browser fails to open.
745fn handle_wiki(
746    _session_state: &mut SessionState,
747    _global_state: &mut GlobalState,
748    args: Vec<String>,
749) -> CommandResult {
750    let url = if args.is_empty() {
751        "https://wiki.veloren.net/".to_string()
752    } else {
753        let query_string = args.join("+");
754
755        format!("https://wiki.veloren.net/w/index.php?search={query_string}")
756    };
757
758    open::that_detached(url)
759        .map(|_| Some(Content::localized("command-wiki-success")))
760        .map_err(|e| {
761            Content::localized_with_args("command-wiki-fail", [(
762                "error",
763                LocalizationArg::from(e.to_string()),
764            )])
765        })
766}
767
768/// Handles [`ClientChatCommand::ResetTutorial`]
769///
770/// Useful for debugging the tutorial or showing the tutorial again.
771fn handle_reset_tutorial(
772    _session_state: &mut SessionState,
773    global_state: &mut GlobalState,
774    _args: Vec<String>,
775) -> CommandResult {
776    global_state.profile.tutorial = Default::default();
777    Ok(Some(Content::localized("command-reset_tutorial-success")))
778}
779
780/// Trait for types that can provide tab completion suggestions.
781///
782/// This trait is implemented by types that can generate a list of possible
783/// completions for a partial input string.
784trait TabComplete {
785    fn complete(&self, part: &str, client: &Client, i18n: &Localization) -> Vec<String>;
786}
787
788impl TabComplete for ArgumentSpec {
789    fn complete(&self, part: &str, client: &Client, i18n: &Localization) -> Vec<String> {
790        match self {
791            ArgumentSpec::PlayerName(_) => complete_player(part, client),
792            ArgumentSpec::EntityTarget(_) => {
793                // Check if the input starts with the entity target prefix '@'
794                if let Some((spec, end)) = part.split_once(ClientEntityTarget::PREFIX) {
795                    match spec {
796                        // If it's just "@", complete with all possible target keywords
797                        "" => ClientEntityTarget::iter()
798                            .filter_map(|target| {
799                                let ident = target.keyword();
800                                if ident.starts_with(end) {
801                                    Some(format!("@{ident}"))
802                                } else {
803                                    None
804                                }
805                            })
806                            .collect(),
807                        // If it's "@uid", complete with actual UIDs from the ECS
808                        "uid" => {
809                            let end_res = end.trim().parse().map_err(|_| Vec::<String>::new());
810                            client
811                                .state()
812                                .ecs()
813                                .read_storage::<Uid>()
814                                .join()
815                                .filter_map(|uid: &Uid| {
816                                    let u = uid.0;
817                                    match end_res {
818                                        Ok(e) if u > e => Some(format!("uid@{}", u.get())),
819                                        Ok(_) => None,
820                                        Err(_) => None,
821                                    }
822                                })
823                                .collect()
824                        },
825                        _ => vec![],
826                    }
827                } else {
828                    complete_player(part, client)
829                }
830            },
831            ArgumentSpec::SiteName(_) => complete_site(part, client, i18n),
832            ArgumentSpec::Float(_, x, _) => {
833                if part.is_empty() {
834                    vec![format!("{:.1}", x)] // Suggest default with one decimal place
835                } else {
836                    vec![] // No suggestions if already typing
837                }
838            },
839            ArgumentSpec::Integer(_, x, _) => {
840                if part.is_empty() {
841                    vec![format!("{}", x)]
842                } else {
843                    vec![]
844                }
845            },
846            // No specific completion for arbitrary 'Any' arguments
847            ArgumentSpec::Any(_, _) => vec![],
848            ArgumentSpec::Command(_) => complete_command(part, ""),
849            ArgumentSpec::Message(_) => complete_player(part, client),
850            ArgumentSpec::SubCommand => complete_command(part, ""),
851            ArgumentSpec::Enum(_, strings, _) => strings
852                .iter()
853                .filter(|string| string.starts_with(part)) // Filter by partial input
854                .map(|c| c.to_string())
855                .collect(),
856            // Complete with asset paths
857            ArgumentSpec::AssetPath(_, prefix, paths, _) => {
858                // If input starts with '#', search within paths
859                if let Some(part_stripped) = part.strip_prefix('#') {
860                    paths
861                        .iter()
862                        .filter(|string| string.contains(part_stripped))
863                        .filter_map(|c| Some(c.strip_prefix(prefix)?.to_string()))
864                        .collect()
865                } else {
866                    // Otherwise, complete based on path hierarchy
867                    let part_with_prefix = prefix.to_string() + part;
868                    let depth = part_with_prefix.split('.').count();
869                    paths
870                        .iter()
871                        .map(|path| path.as_str().split('.').take(depth).join("."))
872                        .dedup()
873                        .filter(|string| string.starts_with(&part_with_prefix))
874                        .filter_map(|c| Some(c.strip_prefix(prefix)?.to_string()))
875                        .collect()
876                }
877            },
878            ArgumentSpec::Boolean(_, part, _) => ["true", "false"]
879                .iter()
880                .filter(|string| string.starts_with(part))
881                .map(|c| c.to_string())
882                .collect(),
883            ArgumentSpec::Flag(part) => vec![part.to_string()],
884        }
885    }
886}
887
888/// Returns a list of player names that start with the given partial input.
889fn complete_player(part: &str, client: &Client) -> Vec<String> {
890    client
891        .player_list()
892        .values()
893        .map(|player_info| &player_info.player_alias)
894        .filter(|alias| alias.starts_with(part))
895        .cloned()
896        .collect()
897}
898
899/// Returns a list of site names that start with the given partial input.
900fn complete_site(mut part: &str, client: &Client, i18n: &Localization) -> Vec<String> {
901    if let Some(p) = part.strip_prefix('"') {
902        part = p;
903    }
904    client
905        .sites()
906        .values()
907        .filter_map(|site| match site.marker.kind {
908            common::map::MarkerKind::Cave => None,
909            // TODO: A bit of a hack: no guarantee that label will be the site name!
910            _ => Some(i18n.get_content(site.marker.label.as_ref()?)),
911        })
912        .filter(|name| name.starts_with(part))
913        .map(|name| {
914            if name.contains(' ') {
915                format!("\"{}\"", name)
916            } else {
917                name.clone()
918            }
919        })
920        .collect()
921}
922
923/// Gets the byte index of the nth word in a string.
924fn nth_word(line: &str, n: usize) -> Option<usize> {
925    let mut is_space = false;
926    let mut word_counter = 0;
927
928    for (i, c) in line.char_indices() {
929        match (is_space, c.is_whitespace()) {
930            (true, true) => {},
931            // start of a new word
932            (true, false) => {
933                is_space = false;
934                word_counter += 1;
935            },
936            // end of the current word
937            (false, true) => {
938                is_space = true;
939            },
940            (false, false) => {},
941        }
942
943        if word_counter == n {
944            return Some(i);
945        }
946    }
947
948    None
949}
950
951/// Returns a list of [`ClientChatCommand`] and [`ServerChatCommand`] names that
952/// start with the given partial input.
953fn complete_command(part: &str, prefix: &str) -> Vec<String> {
954    ServerChatCommand::iter_with_keywords()
955        .map(|(kwd, _)| kwd)
956        .chain(ClientChatCommand::iter_with_keywords().map(|(kwd, _)| kwd))
957        .filter(|kwd| kwd.starts_with(part))
958        .map(|kwd| format!("{}{}", prefix, kwd))
959        .collect()
960}
961
962/// Main tab completion function for chat input.
963///
964/// This function handles tab completion for both commands and regular chat.
965/// It determines what kind of completion is needed based on the input and
966/// delegates to the appropriate completion function.
967pub fn complete(line: &str, client: &Client, i18n: &Localization, cmd_prefix: &str) -> Vec<String> {
968    // Get the last word in the input line, which is what we're trying to complete
969    // If the line ends with whitespace, we're starting a new word
970    let word = if line.chars().last().is_none_or(char::is_whitespace) {
971        ""
972    } else {
973        line.split_whitespace().last().unwrap_or("")
974    };
975
976    // Check if we're completing a command (starts with the command prefix)
977    if line.starts_with(cmd_prefix) {
978        // Strip the command prefix for easier processing
979        let line = line.strip_prefix(cmd_prefix).unwrap_or(line);
980        let mut iter = line.split_whitespace();
981
982        // Get the command name (first word)
983        let cmd = iter.next().unwrap_or("");
984
985        // If the line ends with whitespace, we're starting a new argument
986        let argument_position = iter.count() + usize::from(word.is_empty());
987
988        // If we're at position 0, we're completing the command name itself
989        if argument_position == 0 {
990            // Completing chat command name. This is the start of the line so the prefix
991            // will be part of it
992            let word = word.strip_prefix(cmd_prefix).unwrap_or(word);
993            return complete_command(word, cmd_prefix);
994        }
995
996        // Try to parse the command to get its argument specifications
997        let args = {
998            if let Ok(cmd) = cmd.parse::<ServerChatCommand>() {
999                Some(cmd.data().args)
1000            } else if let Ok(cmd) = cmd.parse::<ClientChatCommand>() {
1001                Some(cmd.data().args)
1002            } else {
1003                None
1004            }
1005        };
1006
1007        if let Some(args) = args {
1008            // If we're completing an argument that's defined in the command's spec
1009            if let Some(arg) = args.get(argument_position - 1) {
1010                // Complete the current argument using its type-specific completion
1011                arg.complete(word, client, i18n)
1012            } else {
1013                // We're past the defined arguments, handle special cases
1014                match args.last() {
1015                    // For subcommands (like in "/sudo player kill"), recursively complete
1016                    Some(ArgumentSpec::SubCommand) => {
1017                        // Find where the subcommand starts in the input
1018                        if let Some(index) = nth_word(line, args.len()) {
1019                            // Recursively complete the subcommand part
1020                            complete(&line[index..], client, i18n, "")
1021                        } else {
1022                            vec![]
1023                        }
1024                    },
1025                    // For message arguments, complete with player names
1026                    Some(ArgumentSpec::Message(_)) => complete_player(word, client),
1027                    _ => vec![],
1028                }
1029            }
1030        } else {
1031            complete_player(word, client)
1032        }
1033    } else {
1034        complete_player(word, client)
1035    }
1036}
1037
1038#[test]
1039fn verify_cmd_list_sorted() {
1040    let mut list = ClientChatCommand::iter()
1041        .map(|c| c.keyword())
1042        .collect::<Vec<_>>();
1043
1044    // Vec::is_sorted is unstable, so we do it the hard way
1045    let list2 = list.clone();
1046    list.sort_unstable();
1047    assert_eq!(list, list2);
1048}
1049
1050#[test]
1051fn test_complete_command() {
1052    assert_eq!(complete_command("mu", "/"), vec!["/mute".to_string()]);
1053    assert_eq!(complete_command("unba", "/"), vec![
1054        "/unban".to_string(),
1055        "/unban_ip".to_string()
1056    ]);
1057    assert_eq!(complete_command("make_", "/"), vec![
1058        "/make_block".to_string(),
1059        "/make_npc".to_string(),
1060        "/make_sprite".to_string(),
1061        "/make_volume".to_string()
1062    ]);
1063}