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