1use std::str::FromStr;
2
3use crate::{
4 GlobalState,
5 render::ExperimentalShader,
6 session::{SessionState, settings_change::change_render_mode},
7};
8use client::Client;
9use common::{
10 cmd::*,
11 comp::Admin,
12 link::Is,
13 mounting::{Mount, Rider, VolumeRider},
14 parse_cmd_args,
15 resources::PlayerEntity,
16 uid::Uid,
17 uuid::Uuid,
18};
19use common_i18n::Content;
20use common_net::sync::WorldSyncExt;
21use itertools::Itertools;
22use levenshtein::levenshtein;
23use specs::{Join, WorldExt};
24use strum::{EnumIter, IntoEnumIterator};
25
26#[derive(Clone, Copy, strum::EnumIter)]
28pub enum ClientChatCommand {
29 Clear,
30 ExperimentalShader,
31 Help,
32 Mute,
33 Unmute,
34}
35
36impl ClientChatCommand {
37 pub fn data(&self) -> ChatCommandData {
38 use ArgumentSpec::*;
39 use Requirement::*;
40 let cmd = ChatCommandData::new;
41 match self {
42 ClientChatCommand::Clear => {
43 cmd(Vec::new(), Content::localized("command-clear-desc"), None)
44 },
45 ClientChatCommand::ExperimentalShader => cmd(
46 vec![Enum(
47 "Shader",
48 ExperimentalShader::iter()
49 .map(|item| item.to_string())
50 .collect(),
51 Optional,
52 )],
53 Content::localized("command-experimental_shader-desc"),
54 None,
55 ),
56 ClientChatCommand::Help => cmd(
57 vec![Command(Optional)],
58 Content::localized("command-help-desc"),
59 None,
60 ),
61 ClientChatCommand::Mute => cmd(
62 vec![PlayerName(Required)],
63 Content::localized("command-mute-desc"),
64 None,
65 ),
66 ClientChatCommand::Unmute => cmd(
67 vec![PlayerName(Required)],
68 Content::localized("command-unmute-desc"),
69 None,
70 ),
71 }
72 }
73
74 pub fn keyword(&self) -> &'static str {
75 match self {
76 ClientChatCommand::Clear => "clear",
77 ClientChatCommand::ExperimentalShader => "experimental_shader",
78 ClientChatCommand::Help => "help",
79 ClientChatCommand::Mute => "mute",
80 ClientChatCommand::Unmute => "unmute",
81 }
82 }
83
84 pub fn help_content(&self) -> Content {
86 let data = self.data();
87
88 let usage = std::iter::once(format!("/{}", self.keyword()))
89 .chain(data.args.iter().map(|arg| arg.usage_string()))
90 .collect::<Vec<_>>()
91 .join(" ");
92
93 Content::localized_with_args("command-help-template", [
94 ("usage", Content::Plain(usage)),
95 ("description", data.description),
96 ])
97 }
98
99 pub fn arg_fmt(&self) -> String {
101 self.data()
102 .args
103 .iter()
104 .map(|arg| match arg {
105 ArgumentSpec::PlayerName(_) => "{}",
106 ArgumentSpec::EntityTarget(_) => "{}",
107 ArgumentSpec::SiteName(_) => "{/.*/}",
108 ArgumentSpec::Float(_, _, _) => "{}",
109 ArgumentSpec::Integer(_, _, _) => "{d}",
110 ArgumentSpec::Any(_, _) => "{}",
111 ArgumentSpec::Command(_) => "{}",
112 ArgumentSpec::Message(_) => "{/.*/}",
113 ArgumentSpec::SubCommand => "{} {/.*/}",
114 ArgumentSpec::Enum(_, _, _) => "{}",
115 ArgumentSpec::AssetPath(_, _, _, _) => "{}",
116 ArgumentSpec::Boolean(_, _, _) => "{}",
117 ArgumentSpec::Flag(_) => "{}",
118 })
119 .collect::<Vec<_>>()
120 .join(" ")
121 }
122
123 pub fn iter() -> impl Iterator<Item = Self> + Clone {
125 <Self as strum::IntoEnumIterator>::iter()
126 }
127
128 pub fn iter_with_keywords() -> impl Iterator<Item = (&'static str, Self)> {
132 Self::iter().map(|c| (c.keyword(), c))
133 }
134}
135
136impl FromStr for ClientChatCommand {
137 type Err = ();
138
139 fn from_str(keyword: &str) -> Result<ClientChatCommand, ()> {
140 Self::iter()
141 .map(|c| (c.keyword(), c))
142 .find_map(|(kwd, command)| (kwd == keyword).then_some(command))
143 .ok_or(())
144 }
145}
146
147#[derive(Clone, Copy)]
148pub enum ChatCommandKind {
149 Client(ClientChatCommand),
150 Server(ServerChatCommand),
151}
152
153impl FromStr for ChatCommandKind {
154 type Err = ();
155
156 fn from_str(s: &str) -> Result<Self, ()> {
157 if let Ok(cmd) = s.parse::<ClientChatCommand>() {
158 Ok(ChatCommandKind::Client(cmd))
159 } else if let Ok(cmd) = s.parse::<ServerChatCommand>() {
160 Ok(ChatCommandKind::Server(cmd))
161 } else {
162 Err(())
163 }
164 }
165}
166
167type CommandResult = Result<Option<String>, String>;
172
173#[derive(EnumIter)]
174enum ClientEntityTarget {
175 Target,
176 Selected,
177 Viewpoint,
178 Mount,
179 Rider,
180 TargetSelf,
181}
182
183impl ClientEntityTarget {
184 const PREFIX: char = '@';
185
186 fn keyword(&self) -> &'static str {
187 match self {
188 ClientEntityTarget::Target => "target",
189 ClientEntityTarget::Selected => "selected",
190 ClientEntityTarget::Viewpoint => "viewpoint",
191 ClientEntityTarget::Mount => "mount",
192 ClientEntityTarget::Rider => "rider",
193 ClientEntityTarget::TargetSelf => "self",
194 }
195 }
196}
197
198fn preproccess_command(
199 session_state: &mut SessionState,
200 command: &ChatCommandKind,
201 args: &mut [String],
202) -> CommandResult {
203 let mut cmd_args = match command {
204 ChatCommandKind::Client(cmd) => cmd.data().args,
205 ChatCommandKind::Server(cmd) => cmd.data().args,
206 };
207 let client = &mut session_state.client.borrow_mut();
208 let ecs = client.state().ecs();
209 let player = ecs.read_resource::<PlayerEntity>().0;
210 let mut command_start = 0;
211 for (i, arg) in args.iter_mut().enumerate() {
212 let mut could_be_entity_target = false;
213 if let Some(post_cmd_args) = cmd_args.get(i - command_start..) {
214 for (j, arg_spec) in post_cmd_args.iter().enumerate() {
215 match arg_spec {
216 ArgumentSpec::EntityTarget(_) => could_be_entity_target = true,
217 ArgumentSpec::SubCommand => {
218 if let Some(sub_command) =
219 ServerChatCommand::iter().find(|cmd| cmd.keyword() == arg)
220 {
221 cmd_args = sub_command.data().args;
222 command_start = i + j + 1;
223 break;
224 }
225 },
226 ArgumentSpec::AssetPath(_, prefix, _, _) => {
227 *arg = prefix.to_string() + arg;
228 },
229 _ => {},
230 }
231 if matches!(arg_spec.requirement(), Requirement::Required) {
232 break;
233 }
234 }
235 } else if matches!(cmd_args.last(), Some(ArgumentSpec::SubCommand)) {
236 could_be_entity_target = true;
237 }
238 if could_be_entity_target && arg.starts_with(ClientEntityTarget::PREFIX) {
239 let target_str = arg.trim_start_matches(ClientEntityTarget::PREFIX);
240 let target = ClientEntityTarget::iter()
241 .find(|t| t.keyword() == target_str)
242 .ok_or_else(|| {
243 let help_string = ClientEntityTarget::iter()
244 .map(|t| t.keyword().to_string())
245 .reduce(|a, b| format!("{a}/{b}"))
246 .unwrap_or_default();
247 format!("Expected {help_string} after '@' found {target_str}")
248 })?;
249 let uid = match target {
250 ClientEntityTarget::Target => session_state
251 .target_entity
252 .and_then(|e| ecs.uid_from_entity(e))
253 .ok_or("Not looking at a valid target".to_string())?,
254 ClientEntityTarget::Selected => session_state
255 .selected_entity
256 .and_then(|(e, _)| ecs.uid_from_entity(e))
257 .ok_or("You don't have a valid target selected".to_string())?,
258 ClientEntityTarget::Viewpoint => session_state
259 .viewpoint_entity
260 .and_then(|e| ecs.uid_from_entity(e))
261 .ok_or("Not viewing from a valid viewpoint entity".to_string())?,
262 ClientEntityTarget::Mount => {
263 if let Some(player) = player {
264 ecs.read_storage::<Is<Rider>>()
265 .get(player)
266 .map(|is_rider| is_rider.mount)
267 .or(ecs.read_storage::<Is<VolumeRider>>().get(player).and_then(
268 |is_rider| match is_rider.pos.kind {
269 common::mounting::Volume::Terrain => None,
270 common::mounting::Volume::Entity(uid) => Some(uid),
271 },
272 ))
273 .ok_or("Not riding a valid entity".to_string())?
274 } else {
275 return Err("No player entity".to_string());
276 }
277 },
278 ClientEntityTarget::Rider => {
279 if let Some(player) = player {
280 ecs.read_storage::<Is<Mount>>()
281 .get(player)
282 .map(|is_mount| is_mount.rider)
283 .ok_or("No valid rider".to_string())?
284 } else {
285 return Err("No player entity".to_string());
286 }
287 },
288 ClientEntityTarget::TargetSelf => player
289 .and_then(|e| ecs.uid_from_entity(e))
290 .ok_or("No player entity")?,
291 };
292 let uid = u64::from(uid);
293 *arg = format!("uid@{uid}");
294 }
295 }
296
297 Ok(None)
298}
299
300pub fn run_command(
305 session_state: &mut SessionState,
306 global_state: &mut GlobalState,
307 cmd: &str,
308 mut args: Vec<String>,
309) -> CommandResult {
310 let command = ChatCommandKind::from_str(cmd)
311 .map_err(|_| invalid_command_message(&session_state.client.borrow(), cmd.to_string()))?;
312
313 preproccess_command(session_state, &command, &mut args)?;
314
315 match command {
316 ChatCommandKind::Server(cmd) => {
317 session_state
318 .client
319 .borrow_mut()
320 .send_command(cmd.keyword().into(), args);
321 Ok(None) },
324 ChatCommandKind::Client(cmd) => run_client_command(session_state, global_state, cmd, args),
325 }
326}
327
328fn invalid_command_message(client: &Client, user_entered_invalid_command: String) -> String {
329 let entity_role = client
330 .state()
331 .read_storage::<Admin>()
332 .get(client.entity())
333 .map(|admin| admin.0);
334
335 let usable_commands = ServerChatCommand::iter()
336 .filter(|cmd| cmd.needs_role() <= entity_role)
337 .map(|cmd| cmd.keyword())
338 .chain(ClientChatCommand::iter().map(|cmd| cmd.keyword()));
339
340 let most_similar_str = usable_commands
341 .clone()
342 .min_by_key(|cmd| levenshtein(&user_entered_invalid_command, cmd))
343 .expect("At least one command exists.");
344
345 let commands_with_same_prefix = usable_commands
346 .filter(|cmd| cmd.starts_with(&user_entered_invalid_command) && cmd != &most_similar_str);
347
348 format!(
349 "Could not find a command named {}. Did you mean any of the following? \n/{} {} \n\nType \
350 /help to see a list of all commands.",
351 user_entered_invalid_command,
352 most_similar_str,
353 commands_with_same_prefix.fold(String::new(), |s, arg| s + "\n/" + arg)
354 )
355}
356
357fn run_client_command(
358 session_state: &mut SessionState,
359 global_state: &mut GlobalState,
360 command: ClientChatCommand,
361 args: Vec<String>,
362) -> CommandResult {
363 let command = match command {
364 ClientChatCommand::Clear => handle_clear,
365 ClientChatCommand::ExperimentalShader => handle_experimental_shader,
366 ClientChatCommand::Help => handle_help,
367 ClientChatCommand::Mute => handle_mute,
368 ClientChatCommand::Unmute => handle_unmute,
369 };
370
371 command(session_state, global_state, args)
372}
373
374fn handle_clear(
375 session_state: &mut SessionState,
376 _global_state: &mut GlobalState,
377 _args: Vec<String>,
378) -> CommandResult {
379 session_state.hud.clear_chat();
380 Ok(None)
381}
382
383fn handle_help(
384 session_state: &mut SessionState,
385 global_state: &mut GlobalState,
386 args: Vec<String>,
387) -> CommandResult {
388 let i18n = global_state.i18n.read();
389
390 if let Some(cmd) = parse_cmd_args!(&args, ServerChatCommand) {
391 Ok(Some(i18n.get_content(&cmd.help_content())))
392 } else if let Some(cmd) = parse_cmd_args!(&args, ClientChatCommand) {
393 Ok(Some(i18n.get_content(&cmd.help_content())))
394 } else {
395 let client = &mut session_state.client.borrow_mut();
396
397 let mut message = String::new();
398 let entity_role = client
399 .state()
400 .read_storage::<Admin>()
401 .get(client.entity())
402 .map(|admin| admin.0);
403
404 ClientChatCommand::iter().for_each(|cmd| {
405 message += &i18n.get_content(&cmd.help_content());
406 message += "\n";
407 });
408 ServerChatCommand::iter()
410 .filter(|cmd| cmd.needs_role() <= entity_role)
411 .for_each(|cmd| {
412 message += &i18n.get_content(&cmd.help_content());
413 message += "\n";
414 });
415 message += &i18n.get_msg("command-help-additional-shortcuts");
416 ServerChatCommand::iter()
417 .filter(|cmd| cmd.needs_role() <= entity_role)
418 .filter_map(|cmd| cmd.short_keyword().map(|k| (k, cmd)))
419 .for_each(|(k, cmd)| {
420 message += &format!(" /{} => /{}", k, cmd.keyword());
421 });
422 Ok(Some(message))
423 }
424}
425
426fn handle_mute(
427 session_state: &mut SessionState,
428 global_state: &mut GlobalState,
429 args: Vec<String>,
430) -> CommandResult {
431 if let Some(alias) = parse_cmd_args!(args, String) {
432 let client = &mut session_state.client.borrow_mut();
433
434 let target = client
435 .player_list()
436 .values()
437 .find(|p| p.player_alias == alias)
438 .ok_or_else(|| format!("Could not find a player named {}", alias))?;
439
440 if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid)) {
441 if target.uuid == me.uuid {
442 return Err("You cannot mute yourself.".to_string());
443 }
444 }
445
446 if global_state
447 .profile
448 .mutelist
449 .insert(target.uuid, alias.clone())
450 .is_none()
451 {
452 Ok(Some(format!("Successfully muted player {}.", alias)))
453 } else {
454 Err(format!("{} is already muted.", alias))
455 }
456 } else {
457 Err("You must specify a player to mute.".to_string())
458 }
459}
460
461fn handle_unmute(
462 session_state: &mut SessionState,
463 global_state: &mut GlobalState,
464 args: Vec<String>,
465) -> CommandResult {
466 if let Some(alias) = parse_cmd_args!(args, String) {
469 if let Some(uuid) = global_state
470 .profile
471 .mutelist
472 .iter()
473 .find(|(_, v)| **v == alias)
474 .map(|(k, _)| *k)
475 {
476 let client = &mut session_state.client.borrow_mut();
477
478 if let Some(me) = client.uid().and_then(|uid| client.player_list().get(&uid)) {
479 if uuid == me.uuid {
480 return Err("You cannot unmute yourself.".to_string());
481 }
482 }
483
484 global_state.profile.mutelist.remove(&uuid);
485 Ok(Some(format!("Successfully unmuted player {}.", alias)))
486 } else {
487 Err(format!("Could not find a muted player named {}.", alias))
488 }
489 } else {
490 Err("You must specify a player to unmute.".to_string())
491 }
492}
493
494fn handle_experimental_shader(
495 _session_state: &mut SessionState,
496 global_state: &mut GlobalState,
497 args: Vec<String>,
498) -> CommandResult {
499 if args.is_empty() {
500 Ok(Some(
501 ExperimentalShader::iter()
502 .map(|s| {
503 let is_active = global_state
504 .settings
505 .graphics
506 .render_mode
507 .experimental_shaders
508 .contains(&s);
509 format!("[{}] {}", if is_active { "x" } else { " " }, s)
510 })
511 .reduce(|mut a, b| {
512 a.push('\n');
513 a.push_str(&b);
514 a
515 })
516 .ok_or("There are no experimental shaders.".to_string())?,
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(format!("Disabled {item}.")))
523 } else {
524 new_render_mode.experimental_shaders.insert(shader);
525 Ok(Some(format!("Enabled {item}.")))
526 };
527
528 change_render_mode(
529 new_render_mode,
530 &mut global_state.window,
531 &mut global_state.settings,
532 );
533
534 res
535 } else {
536 Err(format!(
537 "{item} is not an expermimental shader, use this command with any arguments to \
538 see a complete list."
539 ))
540 }
541 } else {
542 Err(
543 "You must specify a valid experimental shader, to get a list of experimental shaders, \
544 use this command without any arguments."
545 .to_string(),
546 )
547 }
548}
549
550pub fn get_player_uuid(client: &Client, alias: &String) -> Option<Uuid> {
552 client
553 .player_list()
554 .values()
555 .find(|p| p.player_alias == *alias)
556 .map(|p| p.uuid)
557}
558
559trait TabComplete {
560 fn complete(&self, part: &str, client: &Client) -> Vec<String>;
561}
562
563impl TabComplete for ArgumentSpec {
564 fn complete(&self, part: &str, client: &Client) -> Vec<String> {
565 match self {
566 ArgumentSpec::PlayerName(_) => complete_player(part, client),
567 ArgumentSpec::EntityTarget(_) => {
568 if let Some((spec, end)) = part.split_once(ClientEntityTarget::PREFIX) {
569 match spec {
570 "" => ClientEntityTarget::iter()
571 .filter_map(|target| {
572 let ident = target.keyword();
573 if ident.starts_with(end) {
574 Some(format!("@{ident}"))
575 } else {
576 None
577 }
578 })
579 .collect(),
580 "uid" => {
581 if let Some(end) =
582 u64::from_str(end).ok().or(end.is_empty().then_some(0))
583 {
584 client
585 .state()
586 .ecs()
587 .read_storage::<Uid>()
588 .join()
589 .filter_map(|uid| {
590 let uid = u64::from(*uid);
591 if end < uid {
592 Some(format!("uid@{uid}"))
593 } else {
594 None
595 }
596 })
597 .collect()
598 } else {
599 vec![]
600 }
601 },
602 _ => vec![],
603 }
604 } else {
605 complete_player(part, client)
606 }
607 },
608 ArgumentSpec::SiteName(_) => complete_site(part, client),
609 ArgumentSpec::Float(_, x, _) => {
610 if part.is_empty() {
611 vec![format!("{:.1}", x)]
612 } else {
613 vec![]
614 }
615 },
616 ArgumentSpec::Integer(_, x, _) => {
617 if part.is_empty() {
618 vec![format!("{}", x)]
619 } else {
620 vec![]
621 }
622 },
623 ArgumentSpec::Any(_, _) => vec![],
624 ArgumentSpec::Command(_) => complete_command(part, ""),
625 ArgumentSpec::Message(_) => complete_player(part, client),
626 ArgumentSpec::SubCommand => complete_command(part, ""),
627 ArgumentSpec::Enum(_, strings, _) => strings
628 .iter()
629 .filter(|string| string.starts_with(part))
630 .map(|c| c.to_string())
631 .collect(),
632 ArgumentSpec::AssetPath(_, prefix, paths, _) => {
633 if let Some(part_stripped) = part.strip_prefix('#') {
634 paths
635 .iter()
636 .filter(|string| string.contains(part_stripped))
637 .filter_map(|c| Some(c.strip_prefix(prefix)?.to_string()))
638 .collect()
639 } else {
640 let part_with_prefix = prefix.to_string() + part;
641 let depth = part_with_prefix.split('.').count();
642 paths
643 .iter()
644 .map(|path| path.as_str().split('.').take(depth).join("."))
645 .dedup()
646 .filter(|string| string.starts_with(&part_with_prefix))
647 .filter_map(|c| Some(c.strip_prefix(prefix)?.to_string()))
648 .collect()
649 }
650 },
651 ArgumentSpec::Boolean(_, part, _) => ["true", "false"]
652 .iter()
653 .filter(|string| string.starts_with(part))
654 .map(|c| c.to_string())
655 .collect(),
656 ArgumentSpec::Flag(part) => vec![part.to_string()],
657 }
658 }
659}
660
661fn complete_player(part: &str, client: &Client) -> Vec<String> {
662 client
663 .player_list()
664 .values()
665 .map(|player_info| &player_info.player_alias)
666 .filter(|alias| alias.starts_with(part))
667 .cloned()
668 .collect()
669}
670
671fn complete_site(mut part: &str, client: &Client) -> Vec<String> {
672 if let Some(p) = part.strip_prefix('"') {
673 part = p;
674 }
675 client
676 .sites()
677 .values()
678 .filter_map(|site| match site.site.kind {
679 common_net::msg::world_msg::SiteKind::Cave => None,
680 _ => site.site.name.as_ref(),
681 })
682 .filter(|name| name.starts_with(part))
683 .map(|name| {
684 if name.contains(' ') {
685 format!("\"{}\"", name)
686 } else {
687 name.clone()
688 }
689 })
690 .collect()
691}
692
693fn nth_word(line: &str, n: usize) -> Option<usize> {
695 let mut is_space = false;
696 let mut j = 0;
697 for (i, c) in line.char_indices() {
698 match (is_space, c.is_whitespace()) {
699 (true, true) => {},
700 (true, false) => {
701 is_space = false;
702 j += 1;
703 },
704 (false, true) => {
705 is_space = true;
706 },
707 (false, false) => {},
708 }
709 if j == n {
710 return Some(i);
711 }
712 }
713 None
714}
715
716fn complete_command(part: &str, prefix: &str) -> Vec<String> {
717 ServerChatCommand::iter_with_keywords()
718 .map(|(kwd, _)| kwd)
719 .chain(ClientChatCommand::iter_with_keywords().map(|(kwd, _)| kwd))
720 .filter(|kwd| kwd.starts_with(part))
721 .map(|kwd| format!("{}{}", prefix, kwd))
722 .collect()
723}
724
725pub fn complete(line: &str, client: &Client, cmd_prefix: &str) -> Vec<String> {
726 let word = if line.chars().last().is_none_or(char::is_whitespace) {
727 ""
728 } else {
729 line.split_whitespace().last().unwrap_or("")
730 };
731
732 if line.starts_with(cmd_prefix) {
733 let line = line.strip_prefix(cmd_prefix).unwrap_or(line);
734 let mut iter = line.split_whitespace();
735 let cmd = iter.next().unwrap_or("");
736 let i = iter.count() + usize::from(word.is_empty());
737 if i == 0 {
738 let word = word.strip_prefix(cmd_prefix).unwrap_or(word);
741 return complete_command(word, cmd_prefix);
742 }
743
744 let args = {
745 if let Ok(cmd) = cmd.parse::<ServerChatCommand>() {
746 Some(cmd.data().args)
747 } else if let Ok(cmd) = cmd.parse::<ClientChatCommand>() {
748 Some(cmd.data().args)
749 } else {
750 None
751 }
752 };
753
754 if let Some(args) = args {
755 if let Some(arg) = args.get(i - 1) {
756 arg.complete(word, client)
758 } else {
759 match args.last() {
761 Some(ArgumentSpec::SubCommand) => {
762 if let Some(index) = nth_word(line, args.len()) {
763 complete(&line[index..], client, "")
764 } else {
765 vec![]
766 }
767 },
768 Some(ArgumentSpec::Message(_)) => complete_player(word, client),
769 _ => vec![], }
771 }
772 } else {
773 complete_player(word, client)
775 }
776 } else {
777 complete_player(word, client)
779 }
780}
781
782#[test]
783fn verify_cmd_list_sorted() {
784 let mut list = ClientChatCommand::iter()
785 .map(|c| c.keyword())
786 .collect::<Vec<_>>();
787
788 let list2 = list.clone();
790 list.sort_unstable();
791 assert_eq!(list, list2);
792}
793
794#[test]
795fn test_complete_command() {
796 assert_eq!(complete_command("mu", "/"), vec!["/mute".to_string()]);
797 assert_eq!(complete_command("unba", "/"), vec![
798 "/unban".to_string(),
799 "/unban_ip".to_string()
800 ]);
801 assert_eq!(complete_command("make_", "/"), vec![
802 "/make_block".to_string(),
803 "/make_npc".to_string(),
804 "/make_sprite".to_string(),
805 "/make_volume".to_string()
806 ]);
807}